diff --git a/CMakeLists.txt b/CMakeLists.txt index ebb8b8acb..5683016da 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.20) project(ARIEC61850Cpp - VERSION 0.3.0 + VERSION 0.4.0 DESCRIPTION "Incremental C++20 port of the ARIEC61850 protocol stack" LANGUAGES CXX) @@ -15,6 +15,8 @@ add_library(ariec61850_core src/goose/retransmission_schedule.cpp src/goose/pdu_codec.cpp src/goose/frame_codec.cpp + src/goose/subscriber_supervisor.cpp + src/goose/publisher_runtime.cpp src/mms/utc_time.cpp src/mms/data_value.cpp src/mms/data_codec.cpp) @@ -60,4 +62,10 @@ if(ARIEC61850_BUILD_TESTS) ariec61850_apply_warnings(ariec61850_${test_name}_tests) add_test(NAME ariec61850_${test_name}_tests COMMAND ariec61850_${test_name}_tests) endforeach() + + add_executable(ariec61850_goose_runtime_tests tests/test_goose_runtime.cpp) + target_link_libraries(ariec61850_goose_runtime_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_goose_runtime_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_goose_runtime_tests) + add_test(NAME ariec61850_goose_runtime_tests COMMAND ariec61850_goose_runtime_tests) endif() diff --git a/MIGRATION_CHECKLIST.md b/MIGRATION_CHECKLIST.md index 42e9fa02e..f4a2f5307 100644 --- a/MIGRATION_CHECKLIST.md +++ b/MIGRATION_CHECKLIST.md @@ -17,27 +17,49 @@ interoperability is complete. **Phase 0 status: complete.** -## Phase 1 — deterministic process-bus codecs +## Phase 1 — deterministic process-bus codecs and runtime + +### Phase 1A — MMS data foundation - [x] IEC 61850 8-byte UTC time with quality byte. - [x] Recursive MMS Data value model. - [x] MMS AllData BER encoder/decoder. + +### Phase 1B — GOOSE wire codec + - [x] GOOSE PDU model. - [x] GOOSE PDU BER encoder/decoder. - [x] GOOSE `numDatSetEntries` consistency validation. - [x] GOOSE Ethernet/VLAN/process-bus frame encoder/decoder. - [x] GOOSE C#-compatible byte-for-byte golden vectors. - [x] GOOSE malformed/truncated-input regression tests. -- [ ] GOOSE subscriber TTL, `stNum`/`sqNum` supervision, and duplicate detection. -- [ ] GOOSE publisher session and retransmission state machine. + +### Phase 1C — GOOSE supervision and publisher runtime + +- [x] Subscriber TimeAllowedToLive deadline supervision. +- [x] One-shot expiry events and expiration statistics. +- [x] First/retransmission/duplicate classification. +- [x] Sequence gap and sequence regression detection. +- [x] State change, state jump, and state regression detection. +- [x] `stNum` and `sqNum` 32-bit wraparound behavior. +- [x] Expected control-block, dataset, and configuration-revision checks. +- [x] Changed AllData without `stNum` increment detection. +- [x] Publisher session with C#-compatible state/sequence behavior. +- [x] Publisher retransmission runtime with exponential scheduling. +- [x] Schedule reset and `sqNum = 0` on state changes. +- [x] Stable state timestamp across retransmissions. +- [x] Offline encoded-frame output with no raw-network activation. + +### Phase 1D — Sampled Values and evidence hardening + - [ ] Sampled Values ASDU model. - [ ] Sampled Values APDU and Ethernet frame codec. - [ ] Sampled Values sequence supervision and stream diagnostics. - [ ] Captured PCAP equivalence tests from real or simulated IED traffic. - [ ] Decoder fuzzing corpus for BER, GOOSE, and SV. -**Phase 1 status: GOOSE wire codec complete; runtime publisher/subscriber behavior and -Sampled Values remain.** +**Phase 1 status: MMS and GOOSE wire/runtime layers complete; Sampled Values and +capture/fuzz evidence remain.** ## Phase 2 — engineering file formats @@ -84,18 +106,20 @@ Sampled Values remain.** - [x] GCC C++20 build with warnings as errors. - [x] Clang C++20 build with warnings as errors. -- [x] Windows MSVC build for Phase 1A. -- [x] Windows MSVC build and CTest for Phase 1B. +- [x] Windows MSVC build and CTest through Phase 1B. +- [x] Windows MSVC build and CTest for Phase 1C. - [x] MMS C#-derived golden vector. - [x] GOOSE PDU C#-derived golden vector. - [x] GOOSE complete Ethernet frame golden vector with VLAN and process-bus header. - [x] Malformed BER and GOOSE count-mismatch rejection. +- [x] Deterministic subscriber and publisher runtime regression suite. - [ ] C# and C++ executable oracle comparison in one CI job. - [ ] Sanitizer jobs and fuzzing. - [ ] Isolated-lab IED interoperability. ## Safety gate -Active GOOSE publishing, MMS write/control, and raw-Ethernet transmission remain outside -the enabled runtime surface. They should only be enabled after sequence, timeout, -cancellation, disconnect cleanup, PCAP equivalence, and isolated-lab tests pass. +The Phase 1C publisher is an offline state machine that returns encoded bytes. Active +Npcap transmission, MMS write/control, and IED operation remain outside the enabled +runtime surface until timeout, cancellation, disconnect cleanup, PCAP equivalence, and +isolated-lab tests pass. diff --git a/PORT_STATUS.md b/PORT_STATUS.md index 2a0c44f34..72c6db0a3 100644 --- a/PORT_STATUS.md +++ b/PORT_STATUS.md @@ -1,42 +1,67 @@ # C++ Port Status -## Current milestone: Phase 1B - -The C++20 port now includes the deterministic foundation, MMS Data values, IEC 61850 UTC -time, and the complete GOOSE wire codec from BER PDU through Ethernet/VLAN/process-bus -framing. - -| Original C# area | C++ target | Status | -|---|---|---| -| ASN.1 BER primitives | `asn1` | Ported and tested | -| Ethernet, VLAN, process-bus header | `ethernet` | Ported and tested | -| Classic PCAP | `capture` | Ported and tested | -| GOOSE retransmission schedule | `goose/retransmission_schedule` | Ported and tested | -| IEC 61850 UTC time | `mms/utc_time` | Ported and tested | -| MMS Data / AllData | `mms/data_value`, `mms/data_codec` | Ported and tested | -| GOOSE PDU | `goose/pdu`, `goose/pdu_codec` | Ported and tested | -| GOOSE Ethernet frame | `goose/frame`, `goose/frame_codec` | Ported and tested | -| GOOSE runtime publisher/subscriber | Phase 1C | Pending | -| Sampled Values | Phase 1D | Pending | -| SCL and COMTRADE | Phase 2 | Pending | -| TPKT/COTP/ACSE/MMS association | Phase 3 | Pending | -| Reporting, control, and file service | Phase 4 | Pending | -| Simulator, CLI, and native UI | Phase 5 | Pending | - -## Phase 1B validation - -- C++20 GCC 14.2, warnings as errors: passed. -- C++20 Clang 17, warnings as errors: passed. -- Three CTest executables: core, MMS, and GOOSE. -- GOOSE PDU golden vector matches the C# encoder field order and BER representation. -- Complete VLAN-tagged GOOSE Ethernet frame has a byte-for-byte golden vector. -- PDU and frame round trips pass. -- Dataset entry-count mismatch, invalid UTC time, wrong outer tag, and wrong EtherType are rejected. -- No external C++ runtime dependency is required. - -See `MIGRATION_CHECKLIST.md` for the complete roadmap and current checkboxes. +## Current milestone + +**Phase 1C — GOOSE subscriber supervision and publisher runtime is implemented.** + +The C++ stack now covers the deterministic path from MMS values through a complete +GOOSE Ethernet frame and the state machines immediately above that wire codec. + +## Delivered modules + +| Area | Status | +|---|---| +| BER primitives | Complete | +| Ethernet, VLAN, process-bus header | Complete | +| Classic PCAP reader/writer | Complete | +| IEC 61850 UTC time | Complete | +| MMS Data / AllData codec | Complete | +| GOOSE PDU codec | Complete | +| GOOSE Ethernet frame codec | Complete | +| GOOSE subscriber supervision | Complete | +| GOOSE publisher session/runtime | Complete, offline frame output only | +| Sampled Values | Not started | +| SCL and COMTRADE | Not started | +| TPKT/COTP/ACSE/MMS association | Not started | +| Reporting and control | Not started | +| Simulator and native UI | Not started | + +## Phase 1C behavior + +Subscriber supervision includes: + +- TimeAllowedToLive deadline calculation and one-shot expiry reporting; +- arrival-gap tracking; +- duplicate, retransmission, sequence-gap, sequence-regression, state-change, + state-jump, and state-regression classification; +- `uint32` counter wraparound; +- expected stream identity validation; +- configuration revision change indication; and +- detection of changed AllData while `stNum` remains unchanged. + +Publisher runtime includes: + +- initial publication with configured state and sequence counters; +- state-change publication with `stNum` increment and `sqNum = 0`; +- retransmissions with `sqNum` increment and wraparound; +- exponential min-time to max-time scheduling; +- schedule reset after every state change; and +- a deterministic `poll(now)` API that avoids sleeping threads and timing-flaky tests. + +## Validation + +- GNU C++ 14.2, Release, warnings as errors: passed. +- Clang 17, Release, warnings as errors: passed. +- Four CTest executables passed under both compilers: + - core foundation; + - MMS values; + - GOOSE wire codec; and + - GOOSE subscriber/publisher runtime. +- Windows MSVC verification is performed by GitHub Actions for every stacked PR. ## Safety boundary -This milestone provides offline codecs only. It does not start a raw-Ethernet publisher, -subscribe to a network interface, perform MMS writes, or execute IED control. +No network adapter is opened by Phase 1C. The publisher runtime only returns encoded +Ethernet-frame bytes. Npcap transmission, background network threads, active GOOSE +publishing, MMS writes, and IED controls remain disabled until PCAP equivalence and +isolated-lab interoperability are completed. diff --git a/README.md b/README.md index 0909098c1..eb5f51865 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,69 @@ # ARIEC61850 C++ Port -Incremental C++20 migration of the ARIEC61850 C# stack. The C# implementation remains -the behavioral oracle while each module gains byte-for-byte vectors and malformed-input -tests. +Incremental C++20 migration of the ARIEC61850 C# protocol stack. The C# implementation +remains the behavioral oracle until cross-language comparison and isolated-laboratory +interoperability are complete. -## Implemented through Phase 1B +## Implemented -- BER primitives, Ethernet/VLAN/process-bus framing, PCAP, and GOOSE retransmission timing. -- IEC 61850 UTC time and recursive MMS Data / AllData codecs. -- GOOSE PDU encoder/decoder for `gocbRef`, `timeAllowedToLive`, `datSet`, `goID`, `t`, - `stNum`, `sqNum`, `test`, `confRev`, `ndsCom`, `numDatSetEntries`, and `allData`. -- Complete GOOSE Ethernet frame encoder/decoder with optional VLAN. -- GCC, Clang, and MSVC GitHub Actions coverage. -- C#-compatible MMS and GOOSE golden vectors. +### Phase 0 — portable foundation -See [`MIGRATION_CHECKLIST.md`](MIGRATION_CHECKLIST.md) for detailed progress. +- 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. -## Build +### Phase 1A — MMS data foundation -```bash -cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -cmake --build build --parallel -ctest --test-dir build --output-on-failure -``` +- IEC 61850 8-byte UTC time and quality byte. +- Recursive MMS Data values and AllData BER codec. +- C#-derived byte-for-byte golden vectors. -For Visual Studio multi-configuration builds: +### Phase 1B — GOOSE wire codec + +- Complete GOOSE PDU encode/decode. +- Ethernet/VLAN/process-bus GOOSE frame encode/decode. +- Dataset count and malformed-frame validation. + +### Phase 1C — GOOSE runtime state machines + +- Subscriber TTL supervision and one-shot expiry events. +- `stNum`/`sqNum` classification: first, retransmission, duplicate, gap, regression, + state change, state jump, and state regression. +- 32-bit sequence and state wraparound handling. +- Expected control-block, dataset, and configuration-revision identity checks. +- Detection of data changes without a matching `stNum` increment. +- Publisher session matching the C# counter semantics. +- Deterministic publisher runtime with exponential retransmission scheduling. +- State timestamps remain stable during retransmission and change only on a state event. + +The publisher runtime returns encoded frames to its caller. It does not open Npcap, +raw sockets, or a network interface. + +## Build + +### Windows — Visual Studio ```powershell -cmake -S . -B build -A x64 +cmake -S . -B build -A x64 -DARIEC61850_WARNINGS_AS_ERRORS=ON cmake --build build --config Release --parallel ctest --test-dir build -C Release --output-on-failure ``` +### Linux — GCC or Clang + +```bash +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DARIEC61850_WARNINGS_AS_ERRORS=ON +cmake --build build --parallel +ctest --test-dir build --output-on-failure +``` + ## Migration rule -A module is complete only after C++ tests, C#-versus-C++ vectors, malformed-input coverage, -and relevant PCAP or laboratory evidence. Active publishing and control remain disabled -until their state-machine safety gates pass. +1. Capture deterministic C# behavior and byte vectors. +2. Port the same behavior to C++. +3. Run strict multi-compiler builds and semantic state-machine tests. +4. Compare real or simulated PCAP/SCL evidence. +5. Enable active transport only after safety and interoperability gates pass. + +See `MIGRATION_CHECKLIST.md` for the detailed progress ledger. diff --git a/VALIDATION.txt b/VALIDATION.txt index 29d94b50f..8534b7a55 100644 --- a/VALIDATION.txt +++ b/VALIDATION.txt @@ -1,23 +1,33 @@ -ARIEC61850 C++ Phase 1A validation +ARIEC61850 C++ Phase 1C validation +Date: 2026-08-06 -Local toolchains: -- GNU C++ 14.2.0, C++20, -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror: PASS -- Clang 17.0.0, C++20, same strict warning policy: PASS +GNU C++ 14.2 / Release / warnings-as-errors: + Configure: PASS + Build: PASS + CTest: PASS (4/4 test executables) -CTest: -- ariec61850_core_tests: PASS -- ariec61850_mms_tests: PASS -- Internal checks: 12/12 PASS +Clang 17 / Release / warnings-as-errors: + Configure: PASS + Build: PASS + CTest: PASS (4/4 test executables) -New parity evidence: -- IEC 61850 UTC time vector: 6A2B77254000000A -- MMS AllData vector: byte-for-byte match with the C# MmsDataCodec behavior -- Recursive Structure, BitString, Unknown high-tag, and malformed nested TLV coverage: PASS +Windows MSVC / Release / warnings-as-errors / GitHub Actions: + Configure: PASS + Build: PASS + CTest: PASS (4/4 test executables) -CI added: -- Ubuntu GCC -- Ubuntu Clang -- Windows MSVC +Test executables: + 1. ariec61850_core_tests + 2. ariec61850_mms_tests + 3. ariec61850_goose_tests + 4. ariec61850_goose_runtime_tests -Safety boundary: -No active IED control or raw-Ethernet publisher session is enabled by this phase. +Phase 1C runtime checks: + - subscriber sequence and state classification + - TTL expiry and one-shot timeout reporting + - expected stream identity rejection + - state and sequence counter wraparound + - publisher C# sequence semantics + - retransmission schedule and state-change reset + - stable state timestamp across retransmissions + - publisher state and sequence wraparound diff --git a/include/ariec61850/goose/publisher_runtime.hpp b/include/ariec61850/goose/publisher_runtime.hpp new file mode 100644 index 000000000..234ed98b1 --- /dev/null +++ b/include/ariec61850/goose/publisher_runtime.hpp @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "ariec61850/goose/frame.hpp" +#include "ariec61850/goose/retransmission_schedule.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::goose { + +struct GoosePublication final { + GooseFrame frame; + std::vector ethernet_bytes; +}; + +class GoosePublisherSession final { +public: + explicit GoosePublisherSession( + GooseFrame frame_template, + std::uint32_t initial_state_number = 1U, + std::uint32_t initial_sequence_number = 0U); + + [[nodiscard]] GoosePublication publish_initial( + std::span values, + mms::Iec61850UtcTime timestamp, + bool test = false, + bool needs_commissioning = false); + + [[nodiscard]] GoosePublication publish_state_change( + std::span values, + mms::Iec61850UtcTime timestamp, + bool test = false, + bool needs_commissioning = false); + + [[nodiscard]] GoosePublication publish_retransmission(); + + [[nodiscard]] bool has_state() const noexcept { return has_state_; } + [[nodiscard]] std::uint32_t state_number() const noexcept { return state_number_; } + [[nodiscard]] std::uint32_t next_sequence_number() const noexcept { + return sequence_number_; + } + +private: + [[nodiscard]] GoosePublication emit(); + static std::uint32_t increment_state_number(std::uint32_t current) noexcept; + static std::uint32_t increment_sequence_number(std::uint32_t current) noexcept; + + GooseFrame frame_template_; + std::vector values_; + mms::Iec61850UtcTime state_timestamp_{}; + std::uint32_t state_number_{1U}; + std::uint32_t sequence_number_{}; + bool test_{}; + bool needs_commissioning_{}; + bool has_state_{}; +}; + +class GoosePublisherRuntime final { +public: + using clock = std::chrono::steady_clock; + + GoosePublisherRuntime( + GoosePublisherSession session, + std::uint32_t min_time_milliseconds, + std::uint32_t max_time_milliseconds); + + [[nodiscard]] GoosePublication start( + std::span values, + mms::Iec61850UtcTime timestamp, + clock::time_point now, + bool test = false, + bool needs_commissioning = false); + + [[nodiscard]] GoosePublication state_change( + std::span values, + mms::Iec61850UtcTime timestamp, + clock::time_point now, + bool test = false, + bool needs_commissioning = false); + + [[nodiscard]] std::optional poll(clock::time_point now); + + void stop() noexcept; + + [[nodiscard]] bool running() const noexcept { return running_; } + [[nodiscard]] std::optional next_due() const noexcept { + return next_due_; + } + [[nodiscard]] const GoosePublisherSession& session() const noexcept { return session_; } + +private: + void schedule_next(clock::time_point now) noexcept; + + GoosePublisherSession session_; + RetransmissionSchedule schedule_; + std::optional next_due_; + bool running_{}; +}; + +} // namespace ar::iec61850::goose diff --git a/include/ariec61850/goose/subscriber_supervisor.hpp b/include/ariec61850/goose/subscriber_supervisor.hpp new file mode 100644 index 000000000..020cfa8c3 --- /dev/null +++ b/include/ariec61850/goose/subscriber_supervisor.hpp @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "ariec61850/goose/pdu.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::goose { + +enum class GooseSequenceStatus : std::uint8_t { + first, + retransmission, + state_change, + duplicate, + sequence_gap, + sequence_regression, + state_jump, + state_regression, + identity_mismatch +}; + +struct GooseSubscriberOptions final { + std::optional expected_go_cb_ref; + std::optional expected_data_set_reference; + std::optional expected_configuration_revision; +}; + +struct GooseSupervisionResult final { + GooseSequenceStatus status{GooseSequenceStatus::first}; + bool accepted{true}; + bool expired_before_arrival{}; + bool value_changed{}; + bool value_changed_without_state_increment{}; + bool state_change_sequence_not_zero{}; + bool configuration_revision_changed{}; + std::uint32_t missed_sequence_count{}; + std::chrono::milliseconds arrival_gap{}; + std::optional expires_at; +}; + +struct GooseExpiryEvent final { + std::chrono::steady_clock::time_point deadline{}; + std::uint32_t state_number{}; + std::uint32_t sequence_number{}; + std::uint32_t time_allowed_to_live_milliseconds{}; +}; + +struct GooseSubscriberStatistics final { + std::uint64_t accepted_count{}; + std::uint64_t rejected_identity_count{}; + std::uint64_t retransmission_count{}; + std::uint64_t state_change_count{}; + std::uint64_t duplicate_count{}; + std::uint64_t sequence_gap_count{}; + std::uint64_t sequence_regression_count{}; + std::uint64_t state_jump_count{}; + std::uint64_t state_regression_count{}; + std::uint64_t expiration_count{}; + std::uint64_t value_change_without_state_increment_count{}; +}; + +class GooseSubscriberSupervisor final { +public: + using clock = std::chrono::steady_clock; + + explicit GooseSubscriberSupervisor(GooseSubscriberOptions options = {}); + + [[nodiscard]] GooseSupervisionResult observe( + const GoosePdu& pdu, + clock::time_point arrival_time); + + [[nodiscard]] std::optional check_expiry(clock::time_point now); + + void reset() noexcept; + + [[nodiscard]] const GooseSubscriberStatistics& statistics() const noexcept { + return statistics_; + } + [[nodiscard]] std::optional expires_at() const noexcept { + return expires_at_; + } + [[nodiscard]] std::optional last_state_number() const noexcept { + return last_state_number_; + } + [[nodiscard]] std::optional last_sequence_number() const noexcept { + return last_sequence_number_; + } + +private: + [[nodiscard]] bool identity_matches(const GoosePdu& pdu) const noexcept; + [[nodiscard]] GooseSequenceStatus classify( + std::uint32_t state_number, + std::uint32_t sequence_number, + std::uint32_t& missed_sequence_count) const noexcept; + [[nodiscard]] bool record_expiry_if_due(clock::time_point now); + void update_statistics(GooseSequenceStatus status) noexcept; + + GooseSubscriberOptions options_; + GooseSubscriberStatistics statistics_; + std::optional last_arrival_time_; + std::optional expires_at_; + bool expiry_reported_{}; + std::optional last_state_number_; + std::optional last_sequence_number_; + std::optional last_configuration_revision_; + std::vector last_values_fingerprint_; + std::uint32_t last_time_allowed_to_live_milliseconds_{}; +}; + +} // namespace ar::iec61850::goose diff --git a/src/goose/publisher_runtime.cpp b/src/goose/publisher_runtime.cpp new file mode 100644 index 000000000..079f88512 --- /dev/null +++ b/src/goose/publisher_runtime.cpp @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/goose/publisher_runtime.hpp" + +#include "ariec61850/goose/frame_codec.hpp" + +#include +#include +#include + +namespace ar::iec61850::goose { + +GoosePublisherSession::GoosePublisherSession( + GooseFrame frame_template, + const std::uint32_t initial_state_number, + const std::uint32_t initial_sequence_number) + : frame_template_{std::move(frame_template)}, + state_number_{initial_state_number == 0U ? 1U : initial_state_number}, + sequence_number_{initial_sequence_number} {} + +GoosePublication GoosePublisherSession::publish_initial( + const std::span values, + const mms::Iec61850UtcTime timestamp, + const bool test, + const bool needs_commissioning) { + if (has_state_) { + throw std::logic_error("GOOSE publisher session has already been started."); + } + values_ = {values.begin(), values.end()}; + state_timestamp_ = timestamp; + test_ = test; + needs_commissioning_ = needs_commissioning; + has_state_ = true; + return emit(); +} + +GoosePublication GoosePublisherSession::publish_state_change( + const std::span values, + const mms::Iec61850UtcTime timestamp, + const bool test, + const bool needs_commissioning) { + if (!has_state_) { + return publish_initial(values, timestamp, test, needs_commissioning); + } + state_number_ = increment_state_number(state_number_); + sequence_number_ = 0U; + values_ = {values.begin(), values.end()}; + state_timestamp_ = timestamp; + test_ = test; + needs_commissioning_ = needs_commissioning; + return emit(); +} + +GoosePublication GoosePublisherSession::publish_retransmission() { + if (!has_state_) { + throw std::logic_error("GOOSE publisher session must be started before retransmission."); + } + return emit(); +} + +GoosePublication GoosePublisherSession::emit() { + auto frame = frame_template_; + frame.pdu.values = values_; + frame.pdu.timestamp = state_timestamp_; + frame.pdu.state_number = state_number_; + frame.pdu.sequence_number = sequence_number_; + frame.pdu.test = test_; + frame.pdu.needs_commissioning = needs_commissioning_; + + auto bytes = GooseFrameCodec::encode(frame); + sequence_number_ = increment_sequence_number(sequence_number_); + return GoosePublication{std::move(frame), std::move(bytes)}; +} + +std::uint32_t GoosePublisherSession::increment_state_number( + const std::uint32_t current) noexcept { + return current == std::numeric_limits::max() ? 1U : current + 1U; +} + +std::uint32_t GoosePublisherSession::increment_sequence_number( + const std::uint32_t current) noexcept { + return current == std::numeric_limits::max() ? 0U : current + 1U; +} + +GoosePublisherRuntime::GoosePublisherRuntime( + GoosePublisherSession session, + const std::uint32_t min_time_milliseconds, + const std::uint32_t max_time_milliseconds) + : session_{std::move(session)}, + schedule_{min_time_milliseconds, max_time_milliseconds} {} + +GoosePublication GoosePublisherRuntime::start( + const std::span values, + const mms::Iec61850UtcTime timestamp, + const clock::time_point now, + const bool test, + const bool needs_commissioning) { + if (running_) { + throw std::logic_error("GOOSE publisher runtime is already running."); + } + schedule_.reset(); + auto publication = session_.publish_initial(values, timestamp, test, needs_commissioning); + running_ = true; + schedule_next(now); + return publication; +} + +GoosePublication GoosePublisherRuntime::state_change( + const std::span values, + const mms::Iec61850UtcTime timestamp, + const clock::time_point now, + const bool test, + const bool needs_commissioning) { + schedule_.reset(); + auto publication = session_.publish_state_change( + values, timestamp, test, needs_commissioning); + running_ = true; + schedule_next(now); + return publication; +} + +std::optional GoosePublisherRuntime::poll(const clock::time_point now) { + if (!running_ || !next_due_.has_value() || now < *next_due_) { + return std::nullopt; + } + auto publication = session_.publish_retransmission(); + schedule_next(now); + return publication; +} + +void GoosePublisherRuntime::stop() noexcept { + running_ = false; + next_due_.reset(); +} + +void GoosePublisherRuntime::schedule_next(const clock::time_point now) noexcept { + next_due_ = now + std::chrono::milliseconds{schedule_.next_delay_milliseconds()}; +} + +} // namespace ar::iec61850::goose diff --git a/src/goose/subscriber_supervisor.cpp b/src/goose/subscriber_supervisor.cpp new file mode 100644 index 000000000..f7f13fc8a --- /dev/null +++ b/src/goose/subscriber_supervisor.cpp @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/goose/subscriber_supervisor.hpp" + +#include "ariec61850/mms/data_codec.hpp" + +#include +#include +#include + +namespace ar::iec61850::goose { +namespace { + +constexpr std::uint32_t serial_half_range = 0x80000000U; + +std::uint32_t next_state_number(const std::uint32_t value) noexcept { + return value == std::numeric_limits::max() ? 1U : value + 1U; +} + +} // namespace + +GooseSubscriberSupervisor::GooseSubscriberSupervisor(GooseSubscriberOptions options) + : options_{std::move(options)} {} + +GooseSupervisionResult GooseSubscriberSupervisor::observe( + const GoosePdu& pdu, + const clock::time_point arrival_time) { + if (last_arrival_time_.has_value() && arrival_time < *last_arrival_time_) { + throw std::invalid_argument("GOOSE arrival time cannot move backwards."); + } + + GooseSupervisionResult result; + if (!identity_matches(pdu)) { + result.status = GooseSequenceStatus::identity_mismatch; + result.accepted = false; + statistics_.rejected_identity_count++; + return result; + } + + result.expired_before_arrival = expires_at_.has_value() && arrival_time >= *expires_at_; + static_cast(record_expiry_if_due(arrival_time)); + if (last_arrival_time_.has_value()) { + result.arrival_gap = std::chrono::duration_cast( + arrival_time - *last_arrival_time_); + } + + std::uint32_t missed_sequence_count = 0U; + result.status = classify(pdu.state_number, pdu.sequence_number, missed_sequence_count); + result.missed_sequence_count = missed_sequence_count; + result.state_change_sequence_not_zero = + (result.status == GooseSequenceStatus::state_change || + result.status == GooseSequenceStatus::state_jump) && + pdu.sequence_number != 0U; + + const auto fingerprint = mms::MmsDataCodec::encode_all(pdu.values); + result.value_changed = last_state_number_.has_value() && + fingerprint != last_values_fingerprint_; + result.value_changed_without_state_increment = + result.value_changed && last_state_number_.has_value() && + pdu.state_number == *last_state_number_; + if (result.value_changed_without_state_increment) { + statistics_.value_change_without_state_increment_count++; + } + + result.configuration_revision_changed = + last_configuration_revision_.has_value() && + pdu.configuration_revision != *last_configuration_revision_; + + update_statistics(result.status); + statistics_.accepted_count++; + + last_arrival_time_ = arrival_time; + last_state_number_ = pdu.state_number; + last_sequence_number_ = pdu.sequence_number; + last_configuration_revision_ = pdu.configuration_revision; + last_values_fingerprint_ = fingerprint; + last_time_allowed_to_live_milliseconds_ = pdu.time_allowed_to_live_milliseconds; + expiry_reported_ = false; + + if (pdu.time_allowed_to_live_milliseconds == 0U) { + expires_at_.reset(); + } else { + expires_at_ = arrival_time + + std::chrono::milliseconds{pdu.time_allowed_to_live_milliseconds}; + } + result.expires_at = expires_at_; + return result; +} + +std::optional GooseSubscriberSupervisor::check_expiry( + const clock::time_point now) { + if (!record_expiry_if_due(now)) { + return std::nullopt; + } + return GooseExpiryEvent{ + *expires_at_, + last_state_number_.value_or(0U), + last_sequence_number_.value_or(0U), + last_time_allowed_to_live_milliseconds_}; +} + +void GooseSubscriberSupervisor::reset() noexcept { + statistics_ = {}; + last_arrival_time_.reset(); + expires_at_.reset(); + expiry_reported_ = false; + last_state_number_.reset(); + last_sequence_number_.reset(); + last_configuration_revision_.reset(); + last_values_fingerprint_.clear(); + last_time_allowed_to_live_milliseconds_ = 0U; +} + +bool GooseSubscriberSupervisor::identity_matches(const GoosePdu& pdu) const noexcept { + if (options_.expected_go_cb_ref.has_value() && + pdu.go_cb_ref != *options_.expected_go_cb_ref) { + return false; + } + if (options_.expected_data_set_reference.has_value() && + pdu.data_set_reference != *options_.expected_data_set_reference) { + return false; + } + return !options_.expected_configuration_revision.has_value() || + pdu.configuration_revision == *options_.expected_configuration_revision; +} + +GooseSequenceStatus GooseSubscriberSupervisor::classify( + const std::uint32_t state_number, + const std::uint32_t sequence_number, + std::uint32_t& missed_sequence_count) const noexcept { + if (!last_state_number_.has_value() || !last_sequence_number_.has_value()) { + return GooseSequenceStatus::first; + } + + const auto previous_state = *last_state_number_; + const auto previous_sequence = *last_sequence_number_; + + if (state_number != previous_state) { + if (state_number == next_state_number(previous_state)) { + return GooseSequenceStatus::state_change; + } + if ((previous_state == std::numeric_limits::max() && state_number > 1U) || + (previous_state != std::numeric_limits::max() && + state_number > previous_state)) { + return GooseSequenceStatus::state_jump; + } + return GooseSequenceStatus::state_regression; + } + + const auto delta = sequence_number - previous_sequence; + if (delta == 0U) { + return GooseSequenceStatus::duplicate; + } + if (delta < serial_half_range) { + if (delta == 1U) { + return GooseSequenceStatus::retransmission; + } + missed_sequence_count = delta - 1U; + return GooseSequenceStatus::sequence_gap; + } + return GooseSequenceStatus::sequence_regression; +} + +bool GooseSubscriberSupervisor::record_expiry_if_due(const clock::time_point now) { + if (!expires_at_.has_value() || expiry_reported_ || now < *expires_at_) { + return false; + } + expiry_reported_ = true; + statistics_.expiration_count++; + return true; +} + +void GooseSubscriberSupervisor::update_statistics(const GooseSequenceStatus status) noexcept { + switch (status) { + case GooseSequenceStatus::retransmission: + statistics_.retransmission_count++; + break; + case GooseSequenceStatus::state_change: + statistics_.state_change_count++; + break; + case GooseSequenceStatus::duplicate: + statistics_.duplicate_count++; + break; + case GooseSequenceStatus::sequence_gap: + statistics_.sequence_gap_count++; + break; + case GooseSequenceStatus::sequence_regression: + statistics_.sequence_regression_count++; + break; + case GooseSequenceStatus::state_jump: + statistics_.state_jump_count++; + break; + case GooseSequenceStatus::state_regression: + statistics_.state_regression_count++; + break; + default: + break; + } +} + +} // namespace ar::iec61850::goose diff --git a/tests/test_goose_runtime.cpp b/tests/test_goose_runtime.cpp new file mode 100644 index 000000000..e5ffd8f73 --- /dev/null +++ b/tests/test_goose_runtime.cpp @@ -0,0 +1,275 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/goose/publisher_runtime.hpp" +#include "ariec61850/goose/subscriber_supervisor.hpp" +#include "ariec61850/mms/data_value.hpp" +#include "ariec61850/mms/utc_time.hpp" + +#include +#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) + +using namespace std::chrono_literals; + +ar::iec61850::mms::Iec61850UtcTime utc_at(const std::int64_t seconds) { + return { + std::chrono::system_clock::time_point{std::chrono::seconds{seconds}}, + 0U}; +} + +std::vector values(const bool state) { + using ar::iec61850::mms::MmsDataValue; + return { + MmsDataValue::boolean(state), + MmsDataValue::unsigned_integer(state ? 2U : 1U)}; +} + +ar::iec61850::goose::GoosePdu pdu( + const std::uint32_t state_number, + const std::uint32_t sequence_number, + const bool data_state = false, + const std::uint32_t ttl = 1000U) { + ar::iec61850::goose::GoosePdu result; + result.go_cb_ref = "LD0/LLN0$GO$gcb1"; + result.time_allowed_to_live_milliseconds = ttl; + result.data_set_reference = "LD0/LLN0$DS1"; + result.go_id = "GOOSE1"; + result.timestamp = utc_at(1'781'233'445); + result.state_number = state_number; + result.sequence_number = sequence_number; + result.configuration_revision = 2U; + result.values = values(data_state); + return result; +} + +ar::iec61850::goose::GooseFrame frame_template() { + using namespace ar::iec61850; + goose::GooseFrame frame; + frame.destination = ethernet::MacAddress::parse("01:0C:CD:01:00:01"); + frame.source = ethernet::MacAddress::parse("02:00:00:00:00:01"); + frame.vlan = ethernet::VlanTag{4U, 100U}; + frame.app_id = 0x1001U; + frame.pdu = pdu(1U, 0U); + frame.pdu.values.clear(); + return frame; +} + +void subscriber_classifies_sequence_and_state_transitions() { + using namespace ar::iec61850::goose; + GooseSubscriberSupervisor supervisor; + const auto t0 = GooseSubscriberSupervisor::clock::time_point{}; + + const auto first = supervisor.observe(pdu(1U, 0U), t0); + CHECK(first.status == GooseSequenceStatus::first); + CHECK(first.accepted); + CHECK(first.expires_at == t0 + 1000ms); + + const auto retransmission = supervisor.observe(pdu(1U, 1U), t0 + 4ms); + CHECK(retransmission.status == GooseSequenceStatus::retransmission); + CHECK(retransmission.arrival_gap == 4ms); + + const auto duplicate = supervisor.observe(pdu(1U, 1U), t0 + 5ms); + CHECK(duplicate.status == GooseSequenceStatus::duplicate); + + const auto gap = supervisor.observe(pdu(1U, 4U), t0 + 6ms); + CHECK(gap.status == GooseSequenceStatus::sequence_gap); + CHECK(gap.missed_sequence_count == 2U); + + const auto regression = supervisor.observe(pdu(1U, 2U), t0 + 7ms); + CHECK(regression.status == GooseSequenceStatus::sequence_regression); + + const auto changed = supervisor.observe(pdu(2U, 0U, true), t0 + 8ms); + CHECK(changed.status == GooseSequenceStatus::state_change); + CHECK(changed.value_changed); + CHECK(!changed.value_changed_without_state_increment); + + const auto illegal_value_change = supervisor.observe(pdu(2U, 1U, false), t0 + 9ms); + CHECK(illegal_value_change.status == GooseSequenceStatus::retransmission); + CHECK(illegal_value_change.value_changed_without_state_increment); + + const auto state_jump = supervisor.observe(pdu(4U, 0U), t0 + 10ms); + CHECK(state_jump.status == GooseSequenceStatus::state_jump); + + const auto state_regression = supervisor.observe(pdu(3U, 0U), t0 + 11ms); + CHECK(state_regression.status == GooseSequenceStatus::state_regression); + + const auto& statistics = supervisor.statistics(); + CHECK(statistics.accepted_count == 9U); + CHECK(statistics.retransmission_count == 2U); + CHECK(statistics.duplicate_count == 1U); + CHECK(statistics.sequence_gap_count == 1U); + CHECK(statistics.sequence_regression_count == 1U); + CHECK(statistics.state_change_count == 1U); + CHECK(statistics.state_jump_count == 1U); + CHECK(statistics.state_regression_count == 1U); + CHECK(statistics.value_change_without_state_increment_count == 1U); +} + +void subscriber_supervises_ttl_and_identity() { + using namespace ar::iec61850::goose; + GooseSubscriberOptions options; + options.expected_go_cb_ref = "LD0/LLN0$GO$gcb1"; + options.expected_data_set_reference = "LD0/LLN0$DS1"; + options.expected_configuration_revision = 2U; + GooseSubscriberSupervisor supervisor{options}; + const auto t0 = GooseSubscriberSupervisor::clock::time_point{}; + + CHECK(supervisor.observe(pdu(1U, 0U, false, 10U), t0).accepted); + CHECK(!supervisor.check_expiry(t0 + 9ms).has_value()); + const auto expiry = supervisor.check_expiry(t0 + 10ms); + CHECK(expiry.has_value()); + CHECK(expiry->state_number == 1U); + CHECK(expiry->sequence_number == 0U); + CHECK(expiry->time_allowed_to_live_milliseconds == 10U); + CHECK(!supervisor.check_expiry(t0 + 11ms).has_value()); + + const auto late = supervisor.observe(pdu(1U, 1U, false, 10U), t0 + 12ms); + CHECK(late.expired_before_arrival); + + auto wrong = pdu(1U, 2U); + wrong.go_cb_ref = "OTHER/LLN0$GO$gcb1"; + const auto rejected = supervisor.observe(wrong, t0 + 13ms); + CHECK(!rejected.accepted); + CHECK(rejected.status == GooseSequenceStatus::identity_mismatch); + CHECK(supervisor.statistics().expiration_count == 1U); + CHECK(supervisor.statistics().rejected_identity_count == 1U); +} + +void subscriber_handles_counter_wraparound() { + using namespace ar::iec61850::goose; + GooseSubscriberSupervisor sequence_supervisor; + const auto t0 = GooseSubscriberSupervisor::clock::time_point{}; + static_cast(sequence_supervisor.observe( + pdu(7U, std::numeric_limits::max()), t0)); + const auto sequence_wrap = sequence_supervisor.observe(pdu(7U, 0U), t0 + 1ms); + CHECK(sequence_wrap.status == GooseSequenceStatus::retransmission); + + GooseSubscriberSupervisor state_supervisor; + static_cast(state_supervisor.observe( + pdu(std::numeric_limits::max(), 8U), t0)); + const auto state_wrap = state_supervisor.observe(pdu(1U, 0U), t0 + 1ms); + CHECK(state_wrap.status == GooseSequenceStatus::state_change); +} + +void publisher_session_matches_csharp_sequence_semantics() { + using namespace ar::iec61850::goose; + GoosePublisherSession session{frame_template()}; + const auto initial_values = values(false); + const auto changed_values = values(true); + const auto first_timestamp = utc_at(1'781'233'445); + const auto changed_timestamp = utc_at(1'781'233'446); + + const auto first = session.publish_initial(initial_values, first_timestamp); + const auto second = session.publish_retransmission(); + const auto changed = session.publish_state_change(changed_values, changed_timestamp); + + CHECK(first.frame.pdu.state_number == 1U); + CHECK(first.frame.pdu.sequence_number == 0U); + CHECK(second.frame.pdu.state_number == 1U); + CHECK(second.frame.pdu.sequence_number == 1U); + CHECK(second.frame.pdu.timestamp == first_timestamp); + CHECK(changed.frame.pdu.state_number == 2U); + CHECK(changed.frame.pdu.sequence_number == 0U); + CHECK(changed.frame.pdu.timestamp == changed_timestamp); + CHECK(session.state_number() == 2U); + CHECK(session.next_sequence_number() == 1U); + CHECK(!first.ethernet_bytes.empty()); +} + +void publisher_runtime_applies_retransmission_schedule() { + using namespace ar::iec61850::goose; + GoosePublisherRuntime runtime{ + GoosePublisherSession{frame_template()}, 4U, 16U}; + const auto t0 = GoosePublisherRuntime::clock::time_point{}; + const auto first_timestamp = utc_at(1'781'233'445); + const auto changed_timestamp = utc_at(1'781'233'450); + + const auto first = runtime.start(values(false), first_timestamp, t0); + CHECK(first.frame.pdu.sequence_number == 0U); + CHECK(runtime.next_due() == t0 + 4ms); + CHECK(!runtime.poll(t0 + 3ms).has_value()); + + const auto retransmission1 = runtime.poll(t0 + 4ms); + CHECK(retransmission1.has_value()); + CHECK(retransmission1->frame.pdu.sequence_number == 1U); + CHECK(retransmission1->frame.pdu.timestamp == first_timestamp); + CHECK(runtime.next_due() == t0 + 12ms); + + const auto changed = runtime.state_change( + values(true), changed_timestamp, t0 + 5ms, true, true); + CHECK(changed.frame.pdu.state_number == 2U); + CHECK(changed.frame.pdu.sequence_number == 0U); + CHECK(changed.frame.pdu.test); + CHECK(changed.frame.pdu.needs_commissioning); + CHECK(runtime.next_due() == t0 + 9ms); + + const auto retransmission2 = runtime.poll(t0 + 9ms); + CHECK(retransmission2.has_value()); + CHECK(retransmission2->frame.pdu.state_number == 2U); + CHECK(retransmission2->frame.pdu.sequence_number == 1U); + CHECK(retransmission2->frame.pdu.timestamp == changed_timestamp); + CHECK(runtime.next_due() == t0 + 17ms); + + runtime.stop(); + CHECK(!runtime.running()); + CHECK(!runtime.next_due().has_value()); + CHECK(!runtime.poll(t0 + 100ms).has_value()); +} + +void publisher_wraps_state_and_sequence_counters() { + using namespace ar::iec61850::goose; + GoosePublisherSession sequence_session{ + frame_template(), 5U, std::numeric_limits::max()}; + const auto first = sequence_session.publish_initial(values(false), utc_at(1)); + const auto wrapped_sequence = sequence_session.publish_retransmission(); + CHECK(first.frame.pdu.sequence_number == std::numeric_limits::max()); + CHECK(wrapped_sequence.frame.pdu.sequence_number == 0U); + + GoosePublisherSession state_session{ + frame_template(), std::numeric_limits::max(), 0U}; + static_cast(state_session.publish_initial(values(false), utc_at(1))); + const auto wrapped_state = state_session.publish_state_change(values(true), utc_at(2)); + CHECK(wrapped_state.frame.pdu.state_number == 1U); + CHECK(wrapped_state.frame.pdu.sequence_number == 0U); +} + +} // namespace + +int main() { + const std::vector>> tests{ + {"GOOSE subscriber sequence supervision", subscriber_classifies_sequence_and_state_transitions}, + {"GOOSE subscriber TTL and identity", subscriber_supervises_ttl_and_identity}, + {"GOOSE subscriber counter wrap", subscriber_handles_counter_wraparound}, + {"GOOSE publisher session", publisher_session_matches_csharp_sequence_semantics}, + {"GOOSE publisher runtime schedule", publisher_runtime_applies_retransmission_schedule}, + {"GOOSE publisher counter wrap", publisher_wraps_state_and_sequence_counters}}; + + std::size_t passed = 0U; + 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() + << " GOOSE runtime tests.\n"; + return 0; +}