diff --git a/CMakeLists.txt b/CMakeLists.txt index 5683016da..0bf704e30 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.20) project(ARIEC61850Cpp - VERSION 0.4.0 + VERSION 0.5.0 DESCRIPTION "Incremental C++20 port of the ARIEC61850 protocol stack" LANGUAGES CXX) @@ -19,7 +19,13 @@ add_library(ariec61850_core src/goose/publisher_runtime.cpp src/mms/utc_time.cpp src/mms/data_value.cpp - src/mms/data_codec.cpp) + src/mms/data_codec.cpp + src/sampled_values/pdu_codec.cpp + src/sampled_values/frame_codec.cpp + src/sampled_values/quality.cpp + src/sampled_values/payload_inspector.cpp + src/sampled_values/sample_counter.cpp + src/sampled_values/stream_supervisor.cpp) add_library(ARIEC61850::core ALIAS ariec61850_core) @@ -55,7 +61,7 @@ if(ARIEC61850_BUILD_TESTS) ariec61850_apply_warnings(ariec61850_core_tests) add_test(NAME ariec61850_core_tests COMMAND ariec61850_core_tests) - foreach(test_name IN ITEMS mms goose) + foreach(test_name IN ITEMS mms goose sampled_values) add_executable(ariec61850_${test_name}_tests tests/test_${test_name}.cpp) target_link_libraries(ariec61850_${test_name}_tests PRIVATE ARIEC61850::core) target_compile_features(ariec61850_${test_name}_tests PRIVATE cxx_std_20) diff --git a/MIGRATION_CHECKLIST.md b/MIGRATION_CHECKLIST.md index f4a2f5307..079c966a5 100644 --- a/MIGRATION_CHECKLIST.md +++ b/MIGRATION_CHECKLIST.md @@ -27,39 +27,46 @@ interoperability is complete. ### 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. +- [x] GOOSE PDU model and BER codec. +- [x] GOOSE Ethernet/VLAN/process-bus frame codec. +- [x] Dataset-count and malformed-input validation. +- [x] C#-compatible byte-for-byte golden vectors. ### 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: MMS and GOOSE wire/runtime layers complete; Sampled Values and -capture/fuzz evidence remain.** +- [x] Subscriber TTL, sequence, state, identity, and wraparound supervision. +- [x] Publisher session and exponential retransmission state machine. +- [x] Stable state timestamps and offline encoded-frame output. + +### Phase 1D — Sampled Values + +- [x] Sampled Values ASDU model. +- [x] Multi-ASDU SAV PDU model. +- [x] Application-tag-0 BER encoder/decoder. +- [x] Exact `noASDU` consistency validation. +- [x] Optional `datSet`, `refrTm`, `smpRate`, and `smpMod` support. +- [x] Sampled Values Ethernet/VLAN/process-bus frame codec (`0x88BA`). +- [x] `smpCnt` policy and configurable-wrap tracker. +- [x] Continuous, normal-wrap, gap, duplicate, out-of-order, and restart classification. +- [x] Stream identity and configuration-revision supervision. +- [x] Aggregate sequence and missing-sample statistics. +- [x] IEC 61850 Sampled Values quality-word helper. +- [x] Vendor-neutral generic `seqOfData` word inspection. +- [x] Trailing-byte preservation for unknown payload shapes. +- [x] C#-derived PDU and complete Ethernet-frame golden vectors. +- [x] Malformed BER, UTC-time, EtherType, and length rejection tests. + +### Phase 1E — evidence hardening + +- [ ] Captured GOOSE and SV PCAP equivalence tests from real or simulated IED traffic. +- [ ] C# and C++ executable-oracle comparison in one CI job. +- [ ] Sanitizer jobs. +- [ ] BER, GOOSE, and SV decoder fuzzing corpus. +- [ ] Isolated-laboratory GOOSE and SV interoperability. +- [ ] Real-time SV publisher timing-health validation. + +**Phase 1 status: MMS, GOOSE, and Sampled Values deterministic core layers complete; +evidence hardening and active-transport validation remain.** ## Phase 2 — engineering file formats @@ -104,22 +111,22 @@ capture/fuzz evidence remain.** ## Current acceptance evidence -- [x] GCC C++20 build with warnings as errors. -- [x] Clang C++20 build with warnings as errors. -- [x] Windows MSVC build and CTest through Phase 1B. -- [x] Windows MSVC build and CTest for Phase 1C. +- [x] GCC C++20 build with warnings as errors through Phase 1D. +- [x] Clang C++20 build with warnings as errors through Phase 1D. +- [x] Windows MSVC build and CTest through Phase 1D. +- [x] Full GitHub Actions matrix passed with 5/5 CTest executables. - [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. +- [x] GOOSE PDU and Ethernet-frame golden vectors. +- [x] Deterministic GOOSE subscriber/publisher runtime suite. +- [x] Sampled Values PDU and Ethernet-frame golden vectors. +- [x] Sampled Values counter, supervisor, quality, and payload-diagnostics suite. +- [ ] Captured process-bus PCAP equivalence. +- [ ] Fuzzing and sanitizers. - [ ] Isolated-lab IED interoperability. ## Safety gate -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. +GOOSE and Sampled Values publisher components return encoded bytes only. Active Npcap +transmission, real-time SV scheduling, MMS write/control, and IED operation remain +outside the enabled runtime surface until PCAP equivalence, timeout/cancellation, +timing, fuzzing, and isolated-laboratory tests pass. diff --git a/PORT_STATUS.md b/PORT_STATUS.md index 72c6db0a3..4a25f464f 100644 --- a/PORT_STATUS.md +++ b/PORT_STATUS.md @@ -2,10 +2,11 @@ ## Current milestone -**Phase 1C — GOOSE subscriber supervision and publisher runtime is implemented.** +**Phase 1D — Sampled Values core codec and supervision is implemented and CI-validated.** -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. +The C++ stack now covers deterministic MMS data, GOOSE wire/runtime behavior, and the +core Sampled Values path from ASDU/APDU encoding through Ethernet framing and `smpCnt` +supervision. ## Delivered modules @@ -16,52 +17,59 @@ GOOSE Ethernet frame and the state machines immediately above that wire codec. | 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 | +| GOOSE PDU and Ethernet frame codecs | Complete | +| GOOSE subscriber and publisher runtime | Complete, offline frame output only | +| Sampled Values ASDU/APDU/frame codec | Complete | +| Sampled Values counter and stream supervision | Complete | +| Sampled Values quality and generic payload inspection | Complete | +| Captured SV PCAP equivalence | Pending | +| Decoder fuzzing | Pending | | 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 +## Phase 1D behavior -Subscriber supervision includes: +Sampled Values wire support 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. +- application-tag-0 SAV PDU encoding and decoding; +- multi-ASDU sequence handling and exact `noASDU` consistency checks; +- optional dataset reference, reference time, sample rate, and sample mode fields; +- complete Ethernet/VLAN/process-bus frame handling for EtherType `0x88BA`; +- strict rejection of malformed BER, invalid UTC time, wrong EtherType, trailing APDU + data, and impossible process-bus length declarations; and +- C#-derived byte-for-byte APDU and complete Ethernet-frame vectors. -Publisher runtime includes: +Runtime diagnostics include: -- 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. +- sample-counter initialization, increment, configurable wrap, continuity, gaps, + duplicates, out-of-order traffic, and trusted restart classification; +- stream identity and configuration-revision checks with statistics; +- IEC 61850 quality-word encoding/decoding; and +- vendor-neutral big-endian 32-bit payload views without inventing channel semantics. ## Validation -- GNU C++ 14.2, Release, warnings as errors: passed. -- Clang 17, Release, warnings as errors: passed. -- Four CTest executables passed under both compilers: +The final Phase 1D branch passed the complete GitHub Actions matrix: + +- GNU C++ / Release / warnings as errors: passed; +- Clang / Release / warnings as errors: passed; +- Windows MSVC / Release / `/W4 /WX`: passed; and +- five CTest executables passed on every platform: - core foundation; - MMS values; - - GOOSE wire codec; and - - GOOSE subscriber/publisher runtime. -- Windows MSVC verification is performed by GitHub Actions for every stacked PR. + - GOOSE wire codec; + - GOOSE subscriber/publisher runtime; and + - Sampled Values codec and supervision. + +The initial MSVC run identified narrowing construction of `optional` inside +test fixtures. Explicit `uint16_t` values fixed the portability issue without changing +the library API or wire behavior. ## Safety boundary -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. +Sampled Values support currently parses and returns encoded bytes only. No adapter is +opened, and no real-time publisher thread or raw Ethernet transmission is enabled. +PCAP equivalence, sanitizer/fuzz coverage, timing validation, and isolated-laboratory +interoperability remain required before active process-bus transmission. diff --git a/README.md b/README.md index eb5f51865..1148fd194 100644 --- a/README.md +++ b/README.md @@ -27,18 +27,24 @@ interoperability are complete. ### 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. +- Subscriber TTL and `stNum`/`sqNum` supervision. +- Duplicate, gap, regression, state-change, and wraparound classification. - Deterministic publisher runtime with exponential retransmission scheduling. -- State timestamps remain stable during retransmission and change only on a state event. +- Offline encoded-frame output without raw-network activation. -The publisher runtime returns encoded frames to its caller. It does not open Npcap, -raw sockets, or a network interface. +### Phase 1D — Sampled Values core + +- Sampled Values ASDU and multi-ASDU APDU models. +- Application-tag-0 BER encoder/decoder with strict `noASDU` validation. +- Ethernet/VLAN/process-bus frame codec for EtherType `0x88BA`. +- Optional `datSet`, `refrTm`, `smpRate`, and `smpMod` field handling. +- `smpCnt` continuity, normal-wrap, gap, duplicate, out-of-order, and restart tracking. +- Stream identity and configuration-revision supervision with aggregate statistics. +- IEC 61850 quality-word helper. +- Vendor-neutral raw `seqOfData` 32-bit word inspection with trailing-byte preservation. +- C#-derived byte-for-byte PDU and complete Ethernet-frame golden vectors. + +No Phase 1 runtime opens Npcap, raw sockets, or a network interface. ## Build diff --git a/VALIDATION.txt b/VALIDATION.txt index 8534b7a55..c5a66eb73 100644 --- a/VALIDATION.txt +++ b/VALIDATION.txt @@ -1,33 +1,38 @@ -ARIEC61850 C++ Phase 1C validation +ARIEC61850 C++ Phase 1D validation Date: 2026-08-06 -GNU C++ 14.2 / Release / warnings-as-errors: +GNU C++ / Release / warnings-as-errors / GitHub Actions: Configure: PASS Build: PASS - CTest: PASS (4/4 test executables) + CTest: PASS (5/5 test executables) -Clang 17 / Release / warnings-as-errors: +Clang / Release / warnings-as-errors / GitHub Actions: Configure: PASS Build: PASS - CTest: PASS (4/4 test executables) + CTest: PASS (5/5 test executables) -Windows MSVC / Release / warnings-as-errors / GitHub Actions: +Windows MSVC / Release / W4/WX / GitHub Actions: Configure: PASS Build: PASS - CTest: PASS (4/4 test executables) + CTest: PASS (5/5 test executables) Test executables: 1. ariec61850_core_tests 2. ariec61850_mms_tests 3. ariec61850_goose_tests 4. ariec61850_goose_runtime_tests + 5. ariec61850_sampled_values_tests -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 +Phase 1D Sampled Values checks: + - C#-derived SAV PDU byte-for-byte golden vector + - complete VLAN/process-bus Ethernet frame golden vector + - multi-ASDU encode/decode and exact noASDU validation + - malformed outer tag, sequence, UTC time, trailing bytes, EtherType, and length rejection + - smpCnt continuity, wrap, gap, duplicate, out-of-order, and restart tracking + - stream identity/configuration supervision and aggregate statistics + - quality-word round trip + - generic seqOfData word inspection and trailing-byte preservation + +MSVC portability correction: + - explicit uint16_t construction for optional sample-rate, sample-mode, and wrap fixtures + - library API and encoded wire bytes unchanged diff --git a/include/ariec61850/sampled_values/asdu.hpp b/include/ariec61850/sampled_values/asdu.hpp new file mode 100644 index 000000000..cc41e10b6 --- /dev/null +++ b/include/ariec61850/sampled_values/asdu.hpp @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "ariec61850/mms/utc_time.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +struct SampledValueAsdu final { + std::string sv_id; + std::string data_set_reference; + std::uint16_t sample_count{}; + std::uint32_t configuration_revision{1U}; + std::optional reference_time; + std::uint8_t sample_synchronization{2U}; + std::optional sample_rate; + std::optional sample_mode; + std::vector sample_payload; + + friend bool operator==(const SampledValueAsdu&, const SampledValueAsdu&) = default; +}; + +struct SampledValuesPdu final { + std::vector asdus; + + friend bool operator==(const SampledValuesPdu&, const SampledValuesPdu&) = default; +}; + +} // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/sampled_values/frame.hpp b/include/ariec61850/sampled_values/frame.hpp new file mode 100644 index 000000000..b84826d75 --- /dev/null +++ b/include/ariec61850/sampled_values/frame.hpp @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/sampled_values/asdu.hpp" + +#include +#include + +namespace ar::iec61850::sampled_values { + +struct SampledValuesFrame final { + ethernet::MacAddress destination; + ethernet::MacAddress source; + std::optional vlan; + std::uint16_t app_id{}; + std::uint16_t reserved1{}; + std::uint16_t reserved2{}; + SampledValuesPdu pdu; + + friend bool operator==(const SampledValuesFrame&, const SampledValuesFrame&) = default; +}; + +} // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/sampled_values/frame_codec.hpp b/include/ariec61850/sampled_values/frame_codec.hpp new file mode 100644 index 000000000..17d08e524 --- /dev/null +++ b/include/ariec61850/sampled_values/frame_codec.hpp @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "ariec61850/sampled_values/frame.hpp" + +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +class SampledValuesFrameCodec final { +public: + [[nodiscard]] static std::vector encode(const SampledValuesFrame& frame); + [[nodiscard]] static bool try_decode( + std::span bytes, SampledValuesFrame& frame) noexcept; +}; + +} // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/sampled_values/payload_inspector.hpp b/include/ariec61850/sampled_values/payload_inspector.hpp new file mode 100644 index 000000000..3f30ab6c8 --- /dev/null +++ b/include/ariec61850/sampled_values/payload_inspector.hpp @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +enum class GenericPayloadWordRole : std::uint8_t { + standalone_word, + first_word_in_eight_byte_group, + second_word_in_eight_byte_group +}; + +struct GenericPayloadWord final { + std::size_t index{}; + std::size_t byte_offset{}; + GenericPayloadWordRole structural_role{GenericPayloadWordRole::standalone_word}; + std::array raw_bytes{}; + std::int32_t signed_int32{}; + std::uint32_t unsigned_int32{}; + float float32{}; + + [[nodiscard]] bool is_finite_float32() const noexcept; +}; + +struct GenericPayloadInspection final { + std::size_t payload_length{}; + std::size_t complete_word_count{}; + std::size_t trailing_byte_count{}; + bool is_four_byte_aligned{}; + bool has_eight_byte_group_shape{}; + std::vector words; + std::vector trailing_bytes; + std::vector diagnostics; +}; + +class GenericPayloadInspector final { +public: + [[nodiscard]] static GenericPayloadInspection inspect( + std::span payload); +}; + +} // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/sampled_values/pdu_codec.hpp b/include/ariec61850/sampled_values/pdu_codec.hpp new file mode 100644 index 000000000..298802aff --- /dev/null +++ b/include/ariec61850/sampled_values/pdu_codec.hpp @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "ariec61850/sampled_values/asdu.hpp" + +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +class SampledValuesPduCodec final { +public: + [[nodiscard]] static std::vector encode(const SampledValuesPdu& pdu); + [[nodiscard]] static bool try_decode( + std::span apdu, SampledValuesPdu& pdu) noexcept; +}; + +} // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/sampled_values/quality.hpp b/include/ariec61850/sampled_values/quality.hpp new file mode 100644 index 000000000..7880a1382 --- /dev/null +++ b/include/ariec61850/sampled_values/quality.hpp @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +enum class SampledValueValidity : std::uint32_t { + good = 0U, + invalid = 1U, + reserved = 2U, + questionable = 3U +}; + +struct SampledValueQuality final { + SampledValueValidity validity{SampledValueValidity::good}; + bool overflow{}; + bool out_of_range{}; + bool bad_reference{}; + bool oscillatory{}; + bool failure{}; + bool old_data{}; + bool inconsistent{}; + bool inaccurate{}; + bool test{}; + bool operator_blocked{}; + + [[nodiscard]] std::uint32_t to_uint32() const noexcept; + [[nodiscard]] std::vector to_bytes(std::size_t width = 4U) const; + + [[nodiscard]] static SampledValueQuality from_uint32(std::uint32_t encoded) noexcept; + [[nodiscard]] static SampledValueQuality from_bytes( + std::span encoded); + + friend bool operator==(const SampledValueQuality&, const SampledValueQuality&) = default; +}; + +} // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/sampled_values/sample_counter.hpp b/include/ariec61850/sampled_values/sample_counter.hpp new file mode 100644 index 000000000..ef2e994c0 --- /dev/null +++ b/include/ariec61850/sampled_values/sample_counter.hpp @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +enum class SampleCounterMode : std::uint8_t { + free_run, + second_aligned +}; + +class SampleCounterPolicy final { +public: + [[nodiscard]] static std::uint16_t initial_sample_count( + std::chrono::system_clock::time_point timestamp, + double sample_rate_hz, + std::optional wrap, + SampleCounterMode mode); + + [[nodiscard]] static std::uint16_t increment( + std::uint16_t current, + std::optional wrap, + std::uint32_t step = 1U) noexcept; +}; + +enum class SampleCounterTransitionKind : std::uint8_t { + initial, + continuous, + normal_wrap, + gap, + duplicate, + out_of_order, + restart +}; + +struct SampleCounterTransition final { + SampleCounterTransitionKind kind{SampleCounterTransitionKind::initial}; + std::uint16_t actual{}; + std::optional previous; + std::optional expected; + std::optional wrap; + std::uint32_t missing_samples{}; + std::string detail; + + [[nodiscard]] bool is_anomaly() const noexcept; +}; + +class SampleCounterTracker final { +public: + [[nodiscard]] std::optional last() const noexcept { return last_; } + [[nodiscard]] std::optional expected() const noexcept { return expected_; } + + void reset() noexcept; + + [[nodiscard]] SampleCounterTransition observe( + std::uint16_t actual, + std::optional wrap, + bool restart_hint = false); + +private: + [[nodiscard]] static std::uint16_t next( + std::uint16_t value, std::optional wrap) noexcept; + [[nodiscard]] static std::uint32_t resolve_modulus( + std::optional wrap) noexcept; + [[nodiscard]] static std::uint32_t distance_forward( + std::uint16_t from, std::uint16_t to, std::uint32_t modulus) noexcept; + + std::optional last_; + std::optional expected_; +}; + +} // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/sampled_values/stream_supervisor.hpp b/include/ariec61850/sampled_values/stream_supervisor.hpp new file mode 100644 index 000000000..22bb7478f --- /dev/null +++ b/include/ariec61850/sampled_values/stream_supervisor.hpp @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "ariec61850/sampled_values/asdu.hpp" +#include "ariec61850/sampled_values/sample_counter.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +struct StreamExpectation final { + std::optional sv_id; + std::optional data_set_reference; + std::optional configuration_revision; + std::optional sample_counter_wrap; +}; + +struct StreamObservation final { + SampleCounterTransition counter; + bool identity_matches{true}; + bool configuration_changed{}; + std::vector diagnostics; +}; + +struct StreamStatistics final { + std::uint64_t observations{}; + std::uint64_t continuous{}; + std::uint64_t normal_wraps{}; + std::uint64_t gaps{}; + std::uint64_t duplicates{}; + std::uint64_t out_of_order{}; + std::uint64_t restarts{}; + std::uint64_t missing_samples{}; + std::uint64_t identity_mismatches{}; + std::uint64_t configuration_changes{}; +}; + +class SampledValuesStreamSupervisor final { +public: + explicit SampledValuesStreamSupervisor(StreamExpectation expectation = {}); + + [[nodiscard]] const StreamExpectation& expectation() const noexcept { return expectation_; } + [[nodiscard]] const StreamStatistics& statistics() const noexcept { return statistics_; } + [[nodiscard]] std::optional last_configuration_revision() const noexcept { + return last_configuration_revision_; + } + + void reset() noexcept; + + [[nodiscard]] StreamObservation observe( + const SampledValueAsdu& asdu, + bool restart_hint = false); + + [[nodiscard]] std::vector observe( + const SampledValuesPdu& pdu, + bool restart_hint = false); + +private: + StreamExpectation expectation_; + SampleCounterTracker counter_tracker_; + StreamStatistics statistics_; + std::optional last_configuration_revision_; +}; + +} // namespace ar::iec61850::sampled_values diff --git a/src/sampled_values/frame_codec.cpp b/src/sampled_values/frame_codec.cpp new file mode 100644 index 000000000..280afaa00 --- /dev/null +++ b/src/sampled_values/frame_codec.cpp @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/sampled_values/frame_codec.hpp" + +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/sampled_values/pdu_codec.hpp" + +#include + +namespace ar::iec61850::sampled_values { + +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); +} + +bool SampledValuesFrameCodec::try_decode( + const std::span bytes, + SampledValuesFrame& frame) noexcept { + frame = {}; + + ethernet::EthernetFrame ethernet_frame; + if (!ethernet::EthernetFrameCodec::try_decode(bytes, ethernet_frame) || + ethernet_frame.ether_type != ethernet::sampled_values_ethertype) { + return false; + } + + ethernet::ProcessBusFrame process_bus; + if (!ethernet::ProcessBusFrameCodec::try_decode(ethernet_frame, process_bus) || + process_bus.declared_length < ethernet::ProcessBusFrameCodec::header_length) { + return false; + } + + SampledValuesPdu pdu; + if (!SampledValuesPduCodec::try_decode(process_bus.apdu, pdu)) { + return false; + } + + frame.destination = ethernet_frame.destination; + frame.source = ethernet_frame.source; + frame.vlan = ethernet_frame.vlan; + frame.app_id = process_bus.app_id; + frame.reserved1 = process_bus.reserved1; + frame.reserved2 = process_bus.reserved2; + frame.pdu = std::move(pdu); + return true; +} + +} // namespace ar::iec61850::sampled_values diff --git a/src/sampled_values/payload_inspector.cpp b/src/sampled_values/payload_inspector.cpp new file mode 100644 index 000000000..adbff14bf --- /dev/null +++ b/src/sampled_values/payload_inspector.cpp @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/sampled_values/payload_inspector.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { +namespace { + +constexpr std::size_t word_bytes = 4U; +constexpr std::size_t structural_group_bytes = 8U; + +std::uint32_t read_u32_be(const std::span bytes) noexcept { + return (static_cast(bytes[0]) << 24U) | + (static_cast(bytes[1]) << 16U) | + (static_cast(bytes[2]) << 8U) | + static_cast(bytes[3]); +} + +GenericPayloadWordRole resolve_role( + const std::size_t index, + const bool has_eight_byte_group_shape) noexcept { + if (!has_eight_byte_group_shape) { + return GenericPayloadWordRole::standalone_word; + } + return (index % 2U) == 0U + ? GenericPayloadWordRole::first_word_in_eight_byte_group + : GenericPayloadWordRole::second_word_in_eight_byte_group; +} + +} // namespace + +bool GenericPayloadWord::is_finite_float32() const noexcept { + return std::isfinite(float32); +} + +GenericPayloadInspection GenericPayloadInspector::inspect( + const std::span payload) { + GenericPayloadInspection result; + result.payload_length = payload.size(); + result.complete_word_count = payload.size() / word_bytes; + result.trailing_byte_count = payload.size() % word_bytes; + result.is_four_byte_aligned = result.trailing_byte_count == 0U; + result.has_eight_byte_group_shape = + payload.size() >= structural_group_bytes && + (payload.size() % structural_group_bytes) == 0U; + result.words.reserve(result.complete_word_count); + + for (std::size_t index = 0U; index < result.complete_word_count; ++index) { + const auto offset = index * word_bytes; + const auto bytes = payload.subspan(offset, word_bytes); + const auto unsigned_value = read_u32_be(bytes); + GenericPayloadWord word; + word.index = index; + word.byte_offset = offset; + word.structural_role = resolve_role(index, result.has_eight_byte_group_shape); + for (std::size_t byte_index = 0U; byte_index < word_bytes; ++byte_index) { + word.raw_bytes[byte_index] = bytes[byte_index]; + } + word.unsigned_int32 = unsigned_value; + word.signed_int32 = std::bit_cast(unsigned_value); + word.float32 = std::bit_cast(unsigned_value); + result.words.push_back(word); + } + + if (result.trailing_byte_count > 0U) { + const auto trailing = payload.last(result.trailing_byte_count); + result.trailing_bytes.assign(trailing.begin(), trailing.end()); + } + + if (payload.empty()) { + result.diagnostics.emplace_back("seqOfData is empty."); + return result; + } + + result.diagnostics.emplace_back( + "Generic inspection exposed " + + std::to_string(result.complete_word_count) + + " complete big-endian 32-bit word(s) without assigning channel names or engineering units."); + + if (result.has_eight_byte_group_shape) { + result.diagnostics.emplace_back( + "The payload has an 8-byte grouping shape. This is structural evidence only; " + "the second word is not treated as IEC 61850 quality until trusted engineering " + "evidence resolves it."); + } + + if (result.trailing_byte_count > 0U) { + result.diagnostics.emplace_back( + "The payload contains " + std::to_string(result.trailing_byte_count) + + " trailing byte(s) after the last complete 32-bit word; those bytes are preserved verbatim."); + } + + return result; +} + +} // namespace ar::iec61850::sampled_values diff --git a/src/sampled_values/pdu_codec.cpp b/src/sampled_values/pdu_codec.cpp new file mode 100644 index 000000000..6ddfa6d28 --- /dev/null +++ b/src/sampled_values/pdu_codec.cpp @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/sampled_values/pdu_codec.hpp" + +#include "ariec61850/asn1/ber.hpp" +#include "ariec61850/mms/utc_time.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { +namespace { + +constexpr std::int32_t sav_pdu_application_tag = 0; +constexpr std::int32_t sequence_tag_number = 16; + +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); +} + +std::vector encode_asdu(const SampledValueAsdu& asdu) { + asn1::BerWriter writer; + + const auto sv_id = asn1::BerWriter::encode_ascii(asdu.sv_id); + write_context_primitive(writer, 0, sv_id); + + 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); + } + + const auto sample_count = asn1::BerWriter::encode_unsigned_integer(asdu.sample_count); + write_context_primitive(writer, 2, sample_count); + + const auto configuration_revision = + asn1::BerWriter::encode_unsigned_integer(asdu.configuration_revision); + write_context_primitive(writer, 3, configuration_revision); + + if (asdu.reference_time.has_value()) { + const auto reference_time = asdu.reference_time->to_bytes(); + write_context_primitive(writer, 4, reference_time); + } + + const auto sample_synchronization = + asn1::BerWriter::encode_unsigned_integer(asdu.sample_synchronization); + write_context_primitive(writer, 5, sample_synchronization); + + 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); + } + + write_context_primitive(writer, 7, asdu.sample_payload); + + 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); + } + + return writer.to_vector(); +} + +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); + } + return writer.to_vector(); +} + +bool read_unsigned_exact( + const asn1::BerTlv& field, + const std::uint64_t maximum, + std::uint64_t& value) noexcept { + const auto decoded = asn1::BerReader::read_unsigned_integer(field); + if (!decoded.has_value() || *decoded > maximum) { + return false; + } + value = *decoded; + return true; +} + +bool read_asdu( + const std::span asdu_value, + SampledValueAsdu& asdu) { + asdu = {}; + asdu.configuration_revision = 0U; + asdu.sample_synchronization = 0U; + + for (const auto& field : asn1::BerReader::read_children(asdu_value)) { + if (field.tag_class != asn1::BerClass::context_specific || field.constructed) { + continue; + } + + std::uint64_t integer = 0U; + switch (field.tag_number) { + case 0: + asdu.sv_id = asn1::BerReader::read_ascii_string(field); + break; + case 1: + asdu.data_set_reference = asn1::BerReader::read_ascii_string(field); + break; + case 2: + if (!read_unsigned_exact(field, std::numeric_limits::max(), integer)) { + return false; + } + asdu.sample_count = static_cast(integer); + break; + case 3: + if (!read_unsigned_exact(field, std::numeric_limits::max(), integer)) { + return false; + } + asdu.configuration_revision = static_cast(integer); + break; + case 4: + asdu.reference_time = mms::Iec61850UtcTime::from_bytes(field.value); + break; + case 5: + if (!read_unsigned_exact(field, std::numeric_limits::max(), integer)) { + return false; + } + asdu.sample_synchronization = static_cast(integer); + break; + case 6: + if (!read_unsigned_exact(field, std::numeric_limits::max(), integer)) { + return false; + } + asdu.sample_rate = static_cast(integer); + break; + case 7: + asdu.sample_payload = field.value; + break; + case 8: + if (!read_unsigned_exact(field, std::numeric_limits::max(), integer)) { + return false; + } + asdu.sample_mode = static_cast(integer); + break; + default: + break; + } + } + + return true; +} + +bool read_asdu_sequence( + const std::span sequence_value, + 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.constructed) { + return false; + } + + SampledValueAsdu asdu; + if (!read_asdu(child.value, asdu)) { + return false; + } + asdus.push_back(std::move(asdu)); + } + return true; +} + +} // namespace + +std::vector SampledValuesPduCodec::encode(const SampledValuesPdu& pdu) { + if (pdu.asdus.size() > std::numeric_limits::max()) { + throw std::out_of_range("A Sampled Values PDU contains too many ASDUs."); + } + + asn1::BerWriter content; + const auto no_asdu = asn1::BerWriter::encode_unsigned_integer( + static_cast(pdu.asdus.size())); + write_context_primitive(content, 0, no_asdu); + + const auto sequence = encode_asdu_sequence(pdu.asdus); + content.write_tlv(asn1::BerClass::context_specific, true, 2, sequence); + + return asn1::BerWriter::encode_tlv( + asn1::BerClass::application, + true, + sav_pdu_application_tag, + content.bytes()); +} + +bool SampledValuesPduCodec::try_decode( + const std::span apdu, + SampledValuesPdu& pdu) noexcept { + pdu = {}; + + try { + std::size_t offset = 0U; + asn1::BerTlv outer; + 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.constructed) { + return false; + } + + std::optional declared_asdu_count; + std::vector asdus; + + for (const auto& field : asn1::BerReader::read_children(outer.value)) { + if (field.tag_class != asn1::BerClass::context_specific) { + continue; + } + + if (field.tag_number == 0 && !field.constructed) { + std::uint64_t count = 0U; + if (!read_unsigned_exact( + field, + std::numeric_limits::max(), + count)) { + return false; + } + declared_asdu_count = static_cast(count); + } else if (field.tag_number == 2 && field.constructed) { + if (!read_asdu_sequence(field.value, asdus)) { + return false; + } + } + } + + if (declared_asdu_count.has_value() && + asdus.size() != static_cast(*declared_asdu_count)) { + return false; + } + + pdu.asdus = std::move(asdus); + return true; + } catch (...) { + pdu = {}; + return false; + } +} + +} // namespace ar::iec61850::sampled_values diff --git a/src/sampled_values/quality.cpp b/src/sampled_values/quality.cpp new file mode 100644 index 000000000..6f91bf7af --- /dev/null +++ b/src/sampled_values/quality.cpp @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/sampled_values/quality.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +std::uint32_t SampledValueQuality::to_uint32() const noexcept { + auto value = static_cast(validity); + if (overflow) value |= 1U << 2U; + if (out_of_range) value |= 1U << 3U; + if (bad_reference) value |= 1U << 4U; + if (oscillatory) value |= 1U << 5U; + if (failure) value |= 1U << 6U; + if (old_data) value |= 1U << 7U; + if (inconsistent) value |= 1U << 8U; + if (inaccurate) value |= 1U << 9U; + if (test) value |= 1U << 11U; + if (operator_blocked) value |= 1U << 12U; + return value; +} + +std::vector SampledValueQuality::to_bytes(const std::size_t width) const { + if (width == 0U) { + return {}; + } + + const auto value = to_uint32(); + const std::array encoded{ + static_cast((value >> 24U) & 0xFFU), + static_cast((value >> 16U) & 0xFFU), + static_cast((value >> 8U) & 0xFFU), + static_cast(value & 0xFFU)}; + + std::vector result(width, 0U); + const auto count = std::min(width, encoded.size()); + std::copy_n(encoded.begin(), static_cast(count), result.begin()); + return result; +} + +SampledValueQuality SampledValueQuality::from_uint32(const std::uint32_t encoded) noexcept { + SampledValueQuality quality; + quality.validity = static_cast(encoded & 0x03U); + quality.overflow = (encoded & (1U << 2U)) != 0U; + quality.out_of_range = (encoded & (1U << 3U)) != 0U; + quality.bad_reference = (encoded & (1U << 4U)) != 0U; + quality.oscillatory = (encoded & (1U << 5U)) != 0U; + quality.failure = (encoded & (1U << 6U)) != 0U; + quality.old_data = (encoded & (1U << 7U)) != 0U; + quality.inconsistent = (encoded & (1U << 8U)) != 0U; + quality.inaccurate = (encoded & (1U << 9U)) != 0U; + quality.test = (encoded & (1U << 11U)) != 0U; + quality.operator_blocked = (encoded & (1U << 12U)) != 0U; + return quality; +} + +SampledValueQuality SampledValueQuality::from_bytes( + const std::span encoded) { + std::uint32_t value = 0U; + const auto count = std::min(encoded.size(), 4U); + for (std::size_t index = 0U; index < count; ++index) { + value |= static_cast(encoded[index]) << + static_cast(24U - (index * 8U)); + } + return from_uint32(value); +} + +} // namespace ar::iec61850::sampled_values diff --git a/src/sampled_values/sample_counter.cpp b/src/sampled_values/sample_counter.cpp new file mode 100644 index 000000000..57ee9976a --- /dev/null +++ b/src/sampled_values/sample_counter.cpp @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/sampled_values/sample_counter.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +std::uint16_t SampleCounterPolicy::initial_sample_count( + const std::chrono::system_clock::time_point timestamp, + const double sample_rate_hz, + const std::optional wrap, + const SampleCounterMode mode) { + if (mode == SampleCounterMode::free_run || + sample_rate_hz <= 0.0 || + !std::isfinite(sample_rate_hz)) { + return 0U; + } + + const double samples_per_second = wrap.has_value() && *wrap > 1U + ? static_cast(*wrap) + : sample_rate_hz; + if (samples_per_second <= 0.0 || !std::isfinite(samples_per_second)) { + return 0U; + } + + const auto epoch_seconds = std::chrono::duration( + timestamp.time_since_epoch()).count(); + const auto fraction = epoch_seconds - std::floor(epoch_seconds); + auto sample = static_cast(std::floor(fraction * samples_per_second)); + const auto modulo = wrap.has_value() && *wrap > 1U + ? static_cast(*wrap) + : 65'536LL; + sample %= modulo; + if (sample < 0) { + sample += modulo; + } + return static_cast(sample); +} + +std::uint16_t SampleCounterPolicy::increment( + const std::uint16_t current, + const std::optional wrap, + const std::uint32_t step) noexcept { + if (step == 0U) { + return current; + } + + const auto modulo = wrap.has_value() && *wrap > 1U + ? static_cast(*wrap) + : 65'536ULL; + return static_cast( + (static_cast(current) + step) % modulo); +} + +bool SampleCounterTransition::is_anomaly() const noexcept { + return kind == SampleCounterTransitionKind::gap || + kind == SampleCounterTransitionKind::duplicate || + kind == SampleCounterTransitionKind::out_of_order; +} + +void SampleCounterTracker::reset() noexcept { + last_.reset(); + expected_.reset(); +} + +SampleCounterTransition SampleCounterTracker::observe( + const std::uint16_t actual, + const std::optional wrap, + const bool restart_hint) { + if (restart_hint && last_.has_value()) { + const auto previous = last_; + last_ = actual; + expected_ = next(actual, wrap); + return { + SampleCounterTransitionKind::restart, + actual, + previous, + std::nullopt, + wrap, + 0U, + "The counter state was reset by trusted restart/configuration evidence."}; + } + + if (!last_.has_value()) { + last_ = actual; + expected_ = next(actual, wrap); + return { + SampleCounterTransitionKind::initial, + actual, + std::nullopt, + std::nullopt, + wrap, + 0U, + "Initial sample counter observation."}; + } + + const auto previous = *last_; + if (actual == previous) { + return { + SampleCounterTransitionKind::duplicate, + actual, + previous, + expected_, + wrap, + 0U, + "Duplicate smpCnt " + std::to_string(actual) + "."}; + } + + const auto expected = expected_.value_or(next(previous, wrap)); + if (actual == expected) { + const bool wrapped = actual < previous; + last_ = actual; + expected_ = next(actual, wrap); + return { + wrapped ? SampleCounterTransitionKind::normal_wrap + : SampleCounterTransitionKind::continuous, + actual, + previous, + expected, + wrap, + 0U, + wrapped ? "Normal smpCnt wrap." : "Continuous smpCnt transition."}; + } + + const auto modulus = resolve_modulus(wrap); + const auto forward = distance_forward(expected, actual, modulus); + const auto backward = distance_forward(actual, expected, modulus); + const bool is_forward_gap = forward > 0U && forward < backward; + + last_ = actual; + expected_ = next(actual, wrap); + + if (is_forward_gap) { + return { + SampleCounterTransitionKind::gap, + actual, + previous, + expected, + wrap, + forward, + "smpCnt advanced from expected " + std::to_string(expected) + + " to " + std::to_string(actual) + "; " + + std::to_string(forward) + " sample(s) were not observed."}; + } + + return { + SampleCounterTransitionKind::out_of_order, + actual, + previous, + expected, + wrap, + 0U, + "smpCnt " + std::to_string(actual) + + " is behind expected " + std::to_string(expected) + "."}; +} + +std::uint16_t SampleCounterTracker::next( + const std::uint16_t value, + const std::optional wrap) noexcept { + return SampleCounterPolicy::increment(value, wrap); +} + +std::uint32_t SampleCounterTracker::resolve_modulus( + const std::optional wrap) noexcept { + return wrap.has_value() && *wrap > 1U + ? static_cast(*wrap) + : 65'536U; +} + +std::uint32_t SampleCounterTracker::distance_forward( + const std::uint16_t from, + const std::uint16_t to, + const std::uint32_t modulus) noexcept { + const auto from_value = static_cast(from); + const auto to_value = static_cast(to); + const auto modulus_value = static_cast(modulus); + auto distance = (to_value - from_value) % modulus_value; + if (distance < 0) { + distance += modulus_value; + } + return static_cast(distance); +} + +} // namespace ar::iec61850::sampled_values diff --git a/src/sampled_values/stream_supervisor.cpp b/src/sampled_values/stream_supervisor.cpp new file mode 100644 index 000000000..7e1ea5a35 --- /dev/null +++ b/src/sampled_values/stream_supervisor.cpp @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/sampled_values/stream_supervisor.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +SampledValuesStreamSupervisor::SampledValuesStreamSupervisor( + StreamExpectation expectation) + : expectation_{std::move(expectation)} {} + +void SampledValuesStreamSupervisor::reset() noexcept { + counter_tracker_.reset(); + statistics_ = {}; + last_configuration_revision_.reset(); +} + +StreamObservation SampledValuesStreamSupervisor::observe( + const SampledValueAsdu& asdu, + const bool restart_hint) { + StreamObservation observation; + + if (expectation_.sv_id.has_value() && *expectation_.sv_id != asdu.sv_id) { + observation.identity_matches = false; + observation.diagnostics.push_back( + "svID mismatch: expected '" + *expectation_.sv_id + + "', observed '" + asdu.sv_id + "'."); + } + if (expectation_.data_set_reference.has_value() && + *expectation_.data_set_reference != asdu.data_set_reference) { + observation.identity_matches = false; + observation.diagnostics.push_back( + "datSet mismatch: expected '" + *expectation_.data_set_reference + + "', observed '" + asdu.data_set_reference + "'."); + } + if (expectation_.configuration_revision.has_value() && + *expectation_.configuration_revision != asdu.configuration_revision) { + observation.identity_matches = false; + observation.diagnostics.push_back( + "confRev mismatch: expected " + + std::to_string(*expectation_.configuration_revision) + + ", observed " + std::to_string(asdu.configuration_revision) + "."); + } + + observation.configuration_changed = last_configuration_revision_.has_value() && + *last_configuration_revision_ != asdu.configuration_revision; + observation.counter = counter_tracker_.observe( + asdu.sample_count, + expectation_.sample_counter_wrap, + restart_hint || observation.configuration_changed); + last_configuration_revision_ = asdu.configuration_revision; + + ++statistics_.observations; + if (!observation.identity_matches) { + ++statistics_.identity_mismatches; + } + if (observation.configuration_changed) { + ++statistics_.configuration_changes; + } + + switch (observation.counter.kind) { + case SampleCounterTransitionKind::continuous: + ++statistics_.continuous; + break; + case SampleCounterTransitionKind::normal_wrap: + ++statistics_.normal_wraps; + break; + case SampleCounterTransitionKind::gap: + ++statistics_.gaps; + statistics_.missing_samples += observation.counter.missing_samples; + break; + case SampleCounterTransitionKind::duplicate: + ++statistics_.duplicates; + break; + case SampleCounterTransitionKind::out_of_order: + ++statistics_.out_of_order; + break; + case SampleCounterTransitionKind::restart: + ++statistics_.restarts; + break; + case SampleCounterTransitionKind::initial: + break; + } + + return observation; +} + +std::vector SampledValuesStreamSupervisor::observe( + const SampledValuesPdu& pdu, + const bool restart_hint) { + std::vector observations; + observations.reserve(pdu.asdus.size()); + for (std::size_t index = 0U; index < pdu.asdus.size(); ++index) { + observations.push_back(observe(pdu.asdus[index], restart_hint && index == 0U)); + } + return observations; +} + +} // namespace ar::iec61850::sampled_values diff --git a/tests/test_sampled_values.cpp b/tests/test_sampled_values.cpp new file mode 100644 index 000000000..f1233ec2a --- /dev/null +++ b/tests/test_sampled_values.cpp @@ -0,0 +1,326 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/mms/utc_time.hpp" +#include "ariec61850/sampled_values/asdu.hpp" +#include "ariec61850/sampled_values/frame.hpp" +#include "ariec61850/sampled_values/frame_codec.hpp" +#include "ariec61850/sampled_values/payload_inspector.hpp" +#include "ariec61850/sampled_values/pdu_codec.hpp" +#include "ariec61850/sampled_values/quality.hpp" +#include "ariec61850/sampled_values/sample_counter.hpp" +#include "ariec61850/sampled_values/stream_supervisor.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +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) + +ByteVector from_hex(const std::string& text) { + if ((text.size() % 2U) != 0U) { + throw std::invalid_argument("Hex text must contain an even number of characters."); + } + ByteVector result; + result.reserve(text.size() / 2U); + for (std::size_t index = 0U; index < text.size(); index += 2U) { + result.push_back(static_cast( + std::stoul(text.substr(index, 2U), nullptr, 16))); + } + return result; +} + +std::string to_hex(const ByteVector& bytes) { + std::ostringstream stream; + stream << std::uppercase << std::hex << std::setfill('0'); + for (const auto byte : bytes) { + stream << std::setw(2) << static_cast(byte); + } + return stream.str(); +} + +ar::iec61850::sampled_values::SampledValueAsdu make_reference_asdu() { + using namespace ar::iec61850; + return { + "MU01F1/LLN0$MSVCB01", + "MU01F1/LLN0$PhsMeas1", + 120U, + 3U, + mms::Iec61850UtcTime{ + std::chrono::system_clock::time_point{std::chrono::seconds{1'781'260'260}}, + 0U}, + 2U, + std::uint16_t{4000U}, + std::uint16_t{1U}, + from_hex("0000006400000001000000C800000003")}; +} + +void sampled_values_pdu_matches_csharp_golden_vector() { + using namespace ar::iec61850::sampled_values; + + const SampledValuesPdu pdu{{make_reference_asdu()}}; + const auto encoded = SampledValuesPduCodec::encode(pdu); + CHECK(to_hex(encoded) == + "605E800101A259305780134D55303146312F4C4C4E30244D535643423031" + "81144D55303146312F4C4C4E30245068734D65617331820178830103" + "84086A2BDFE40000000085010286020FA087100000006400000001000000C8" + "00000003880101"); + + SampledValuesPdu decoded; + CHECK(SampledValuesPduCodec::try_decode(encoded, decoded)); + CHECK(decoded == pdu); +} + +void sampled_values_frame_round_trips_vlan_process_bus_header() { + using namespace ar::iec61850; + using namespace ar::iec61850::sampled_values; + + const SampledValuesFrame frame{ + ethernet::MacAddress::parse("01:0C:CD:04:00:01"), + ethernet::MacAddress::parse("02:00:00:00:00:02"), + ethernet::VlanTag{4U, 200U}, + 0x4001U, + 0U, + 0U, + SampledValuesPdu{{make_reference_asdu()}}}; + + const auto encoded = SampledValuesFrameCodec::encode(frame); + CHECK(to_hex(encoded) == + "010CCD040001020000000002810080C888BA4001006800000000" + "605E800101A259305780134D55303146312F4C4C4E30244D535643423031" + "81144D55303146312F4C4C4E30245068734D65617331820178830103" + "84086A2BDFE40000000085010286020FA087100000006400000001000000C8" + "00000003880101"); + + SampledValuesFrame decoded; + CHECK(SampledValuesFrameCodec::try_decode(encoded, decoded)); + CHECK(decoded == frame); + + auto wrong_ethertype = encoded; + wrong_ethertype[16] = 0x88U; + wrong_ethertype[17] = 0xB8U; + CHECK(!SampledValuesFrameCodec::try_decode(wrong_ethertype, decoded)); + + auto impossible_length = encoded; + impossible_length[20] = 0xFFU; + impossible_length[21] = 0xFFU; + CHECK(!SampledValuesFrameCodec::try_decode(impossible_length, decoded)); +} + +void sampled_values_codec_handles_multiple_asdus_and_rejects_malformed_input() { + using namespace ar::iec61850::sampled_values; + + auto first = make_reference_asdu(); + auto second = first; + second.sample_count = 121U; + second.reference_time.reset(); + second.sample_rate.reset(); + second.sample_mode.reset(); + second.data_set_reference.clear(); + second.sample_payload = {0xAAU}; + + const SampledValuesPdu pdu{{first, second}}; + const auto encoded = SampledValuesPduCodec::encode(pdu); + SampledValuesPdu decoded; + CHECK(SampledValuesPduCodec::try_decode(encoded, decoded)); + CHECK(decoded == pdu); + + auto count_mismatch = encoded; + std::size_t count_offset = count_mismatch.size(); + for (std::size_t index = 0U; index + 2U < count_mismatch.size(); ++index) { + if (count_mismatch[index] == 0x80U && + count_mismatch[index + 1U] == 0x01U && + count_mismatch[index + 2U] == 0x02U) { + count_offset = index; + break; + } + } + CHECK(count_offset < count_mismatch.size()); + count_mismatch[count_offset + 2U] = 0x03U; + CHECK(!SampledValuesPduCodec::try_decode(count_mismatch, decoded)); + + auto wrong_outer = encoded; + wrong_outer[0] = 0x61U; + CHECK(!SampledValuesPduCodec::try_decode(wrong_outer, decoded)); + + auto trailing = encoded; + trailing.push_back(0x00U); + CHECK(!SampledValuesPduCodec::try_decode(trailing, decoded)); + + auto wrong_sequence = encoded; + const auto sequence = std::find(wrong_sequence.begin(), wrong_sequence.end(), 0x30U); + CHECK(sequence != wrong_sequence.end()); + *sequence = 0x31U; + CHECK(!SampledValuesPduCodec::try_decode(wrong_sequence, decoded)); + + auto bad_timestamp = SampledValuesPduCodec::encode(SampledValuesPdu{{make_reference_asdu()}}); + std::size_t timestamp_offset = bad_timestamp.size(); + for (std::size_t index = 0U; index + 1U < bad_timestamp.size(); ++index) { + if (bad_timestamp[index] == 0x84U && bad_timestamp[index + 1U] == 0x08U) { + timestamp_offset = index; + break; + } + } + CHECK(timestamp_offset < bad_timestamp.size()); + bad_timestamp[timestamp_offset + 1U] = 0x07U; + CHECK(!SampledValuesPduCodec::try_decode(bad_timestamp, decoded)); +} + +void sample_counter_tracker_distinguishes_wrap_gap_duplicate_and_order() { + using namespace ar::iec61850::sampled_values; + + SampleCounterTracker tracker; + const auto wrap = std::optional{std::uint16_t{4000U}}; + CHECK(tracker.observe(3998U, wrap).kind == SampleCounterTransitionKind::initial); + CHECK(tracker.observe(3999U, wrap).kind == SampleCounterTransitionKind::continuous); + CHECK(tracker.observe(0U, wrap).kind == SampleCounterTransitionKind::normal_wrap); + + const auto gap = tracker.observe(3U, wrap); + CHECK(gap.kind == SampleCounterTransitionKind::gap); + CHECK(gap.missing_samples == 2U); + CHECK(gap.is_anomaly()); + + CHECK(tracker.observe(3U, wrap).kind == SampleCounterTransitionKind::duplicate); + CHECK(tracker.observe(2U, wrap).kind == SampleCounterTransitionKind::out_of_order); + CHECK(tracker.observe(100U, wrap, true).kind == SampleCounterTransitionKind::restart); + + const auto timestamp = std::chrono::system_clock::time_point{ + std::chrono::seconds{100}} + std::chrono::milliseconds{250}; + CHECK(SampleCounterPolicy::initial_sample_count( + timestamp, 4000.0, wrap, SampleCounterMode::second_aligned) == 1000U); + CHECK(SampleCounterPolicy::initial_sample_count( + timestamp, 4000.0, wrap, SampleCounterMode::free_run) == 0U); + CHECK(SampleCounterPolicy::increment(3999U, wrap) == 0U); + CHECK(SampleCounterPolicy::increment(65'535U, std::nullopt) == 0U); +} + +void stream_supervisor_tracks_identity_configuration_and_statistics() { + using namespace ar::iec61850::sampled_values; + + SampledValuesStreamSupervisor supervisor(StreamExpectation{ + std::string{"MU01"}, + std::string{"MU01/LLN0$Dataset1"}, + 3U, + std::uint16_t{4000U}}); + + SampledValueAsdu asdu; + asdu.sv_id = "MU01"; + asdu.data_set_reference = "MU01/LLN0$Dataset1"; + asdu.configuration_revision = 3U; + asdu.sample_count = 10U; + + CHECK(supervisor.observe(asdu).counter.kind == SampleCounterTransitionKind::initial); + asdu.sample_count = 11U; + CHECK(supervisor.observe(asdu).counter.kind == SampleCounterTransitionKind::continuous); + asdu.sample_count = 13U; + CHECK(supervisor.observe(asdu).counter.kind == SampleCounterTransitionKind::gap); + CHECK(supervisor.observe(asdu).counter.kind == SampleCounterTransitionKind::duplicate); + asdu.sample_count = 12U; + CHECK(supervisor.observe(asdu).counter.kind == SampleCounterTransitionKind::out_of_order); + + asdu.configuration_revision = 4U; + asdu.sample_count = 0U; + const auto restart = supervisor.observe(asdu); + CHECK(restart.configuration_changed); + CHECK(restart.counter.kind == SampleCounterTransitionKind::restart); + CHECK(!restart.identity_matches); + CHECK(!restart.diagnostics.empty()); + + const auto& statistics = supervisor.statistics(); + CHECK(statistics.observations == 6U); + CHECK(statistics.continuous == 1U); + CHECK(statistics.gaps == 1U); + CHECK(statistics.duplicates == 1U); + CHECK(statistics.out_of_order == 1U); + CHECK(statistics.restarts == 1U); + CHECK(statistics.missing_samples == 1U); + CHECK(statistics.configuration_changes == 1U); + CHECK(statistics.identity_mismatches == 1U); +} + +void quality_and_generic_payload_diagnostics_preserve_wire_evidence() { + using namespace ar::iec61850::sampled_values; + + const SampledValueQuality quality{ + SampledValueValidity::questionable, + true, + false, + false, + false, + true, + true, + false, + false, + true, + true}; + CHECK(quality.to_uint32() == 0x000018C7U); + CHECK(to_hex(quality.to_bytes()) == "000018C7"); + CHECK(SampledValueQuality::from_uint32(quality.to_uint32()) == quality); + CHECK(SampledValueQuality::from_bytes(quality.to_bytes()) == quality); + + const auto inspection = GenericPayloadInspector::inspect( + from_hex("000003E8FFFFFC18")); + CHECK(inspection.payload_length == 8U); + CHECK(inspection.complete_word_count == 2U); + CHECK(inspection.has_eight_byte_group_shape); + CHECK(inspection.words[0].signed_int32 == 1000); + CHECK(inspection.words[1].signed_int32 == -1000); + CHECK(inspection.words[0].structural_role == + GenericPayloadWordRole::first_word_in_eight_byte_group); + CHECK(inspection.words[1].structural_role == + GenericPayloadWordRole::second_word_in_eight_byte_group); + CHECK(inspection.diagnostics.size() >= 2U); + + const auto trailing = GenericPayloadInspector::inspect( + from_hex("00000001AABB")); + CHECK(!trailing.is_four_byte_aligned); + CHECK(trailing.complete_word_count == 1U); + CHECK(trailing.trailing_bytes == ByteVector({0xAAU, 0xBBU})); +} + +} // namespace + +int main() { + const std::vector>> tests{ + {"SV PDU golden vector", sampled_values_pdu_matches_csharp_golden_vector}, + {"SV Ethernet frame", sampled_values_frame_round_trips_vlan_process_bus_header}, + {"SV malformed input", sampled_values_codec_handles_multiple_asdus_and_rejects_malformed_input}, + {"SV sample counter", sample_counter_tracker_distinguishes_wrap_gap_duplicate_and_order}, + {"SV stream supervisor", stream_supervisor_tracks_identity_configuration_and_statistics}, + {"SV quality and payload diagnostics", quality_and_generic_payload_diagnostics_preserve_wire_evidence}}; + + 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() + << " Sampled Values tests.\n"; + return 0; +}