From 385a39995c6575629629b70b4c478c4f11bf8219 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 6 Aug 2026 06:13:59 +0700 Subject: [PATCH] Port GOOSE PDU and Ethernet frame codecs --- CMakeLists.txt | 66 ++++----- MIGRATION_CHECKLIST.md | 101 +++++++++++++ PORT_STATUS.md | 67 ++++----- README.md | 61 ++++---- include/ariec61850/goose/frame.hpp | 23 +++ include/ariec61850/goose/frame_codec.hpp | 20 +++ include/ariec61850/goose/pdu.hpp | 28 ++++ include/ariec61850/goose/pdu_codec.hpp | 20 +++ src/goose/frame_codec.cpp | 59 ++++++++ src/goose/pdu_codec.cpp | 176 +++++++++++++++++++++++ tests/test_goose.cpp | 165 +++++++++++++++++++++ 11 files changed, 672 insertions(+), 114 deletions(-) create mode 100644 MIGRATION_CHECKLIST.md create mode 100644 include/ariec61850/goose/frame.hpp create mode 100644 include/ariec61850/goose/frame_codec.hpp create mode 100644 include/ariec61850/goose/pdu.hpp create mode 100644 include/ariec61850/goose/pdu_codec.hpp create mode 100644 src/goose/frame_codec.cpp create mode 100644 src/goose/pdu_codec.cpp create mode 100644 tests/test_goose.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 682316e42..ebb8b8acb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.20) project(ARIEC61850Cpp - VERSION 0.2.0 + VERSION 0.3.0 DESCRIPTION "Incremental C++20 port of the ARIEC61850 protocol stack" LANGUAGES CXX) @@ -13,6 +13,8 @@ add_library(ariec61850_core src/ethernet/ethernet.cpp src/capture/pcap.cpp src/goose/retransmission_schedule.cpp + src/goose/pdu_codec.cpp + src/goose/frame_codec.cpp src/mms/utc_time.cpp src/mms/data_value.cpp src/mms/data_codec.cpp) @@ -25,55 +27,37 @@ target_include_directories(ariec61850_core $ $) -if(MSVC) - target_compile_options(ariec61850_core PRIVATE /W4 /permissive-) - if(ARIEC61850_WARNINGS_AS_ERRORS) - target_compile_options(ariec61850_core PRIVATE /WX) - endif() -else() - target_compile_options(ariec61850_core PRIVATE -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion) - if(ARIEC61850_WARNINGS_AS_ERRORS) - target_compile_options(ariec61850_core PRIVATE -Werror) - endif() -endif() - -if(ARIEC61850_BUILD_TESTS) - include(CTest) - enable_testing() - - add_executable(ariec61850_core_tests tests/test_main.cpp) - target_link_libraries(ariec61850_core_tests PRIVATE ARIEC61850::core) - target_compile_features(ariec61850_core_tests PRIVATE cxx_std_20) - +function(ariec61850_apply_warnings target) if(MSVC) - target_compile_options(ariec61850_core_tests PRIVATE /W4 /permissive-) + target_compile_options(${target} PRIVATE /W4 /permissive-) if(ARIEC61850_WARNINGS_AS_ERRORS) - target_compile_options(ariec61850_core_tests PRIVATE /WX) + target_compile_options(${target} PRIVATE /WX) endif() else() - target_compile_options(ariec61850_core_tests PRIVATE -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion) + target_compile_options(${target} PRIVATE -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion) if(ARIEC61850_WARNINGS_AS_ERRORS) - target_compile_options(ariec61850_core_tests PRIVATE -Werror) + target_compile_options(${target} PRIVATE -Werror) endif() endif() +endfunction() - add_test(NAME ariec61850_core_tests COMMAND ariec61850_core_tests) +ariec61850_apply_warnings(ariec61850_core) - add_executable(ariec61850_mms_tests tests/test_mms.cpp) - target_link_libraries(ariec61850_mms_tests PRIVATE ARIEC61850::core) - target_compile_features(ariec61850_mms_tests PRIVATE cxx_std_20) +if(ARIEC61850_BUILD_TESTS) + include(CTest) + enable_testing() - if(MSVC) - target_compile_options(ariec61850_mms_tests PRIVATE /W4 /permissive-) - if(ARIEC61850_WARNINGS_AS_ERRORS) - target_compile_options(ariec61850_mms_tests PRIVATE /WX) - endif() - else() - target_compile_options(ariec61850_mms_tests PRIVATE -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion) - if(ARIEC61850_WARNINGS_AS_ERRORS) - target_compile_options(ariec61850_mms_tests PRIVATE -Werror) - endif() - endif() + add_executable(ariec61850_core_tests tests/test_main.cpp) + target_link_libraries(ariec61850_core_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_core_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_core_tests) + add_test(NAME ariec61850_core_tests COMMAND ariec61850_core_tests) - add_test(NAME ariec61850_mms_tests COMMAND ariec61850_mms_tests) + foreach(test_name IN ITEMS mms goose) + 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) + ariec61850_apply_warnings(ariec61850_${test_name}_tests) + add_test(NAME ariec61850_${test_name}_tests COMMAND ariec61850_${test_name}_tests) + endforeach() endif() diff --git a/MIGRATION_CHECKLIST.md b/MIGRATION_CHECKLIST.md new file mode 100644 index 000000000..42e9fa02e --- /dev/null +++ b/MIGRATION_CHECKLIST.md @@ -0,0 +1,101 @@ +# ARIEC61850 C++ Migration Checklist + +Status legend: `[x]` implemented and regression-tested, `[~]` partially implemented, +`[ ]` not started. The C# repository remains the behavioral oracle until laboratory +interoperability is complete. + +## Phase 0 — portable deterministic foundation + +- [x] C++20 static library and CMake build. +- [x] Strict compiler warnings promoted to errors. +- [x] GitHub Actions matrix: GCC, Clang, and Windows MSVC. +- [x] BER identifier, length, TLV, integer, boolean, float, string, and UTC-time primitives. +- [x] MAC address, VLAN, and Ethernet frame codec. +- [x] IEC 61850 process-bus header and APDU framing. +- [x] Classic PCAP Ethernet reader/writer. +- [x] GOOSE retransmission schedule. + +**Phase 0 status: complete.** + +## Phase 1 — deterministic process-bus codecs + +- [x] IEC 61850 8-byte UTC time with quality byte. +- [x] Recursive MMS Data value model. +- [x] MMS AllData BER encoder/decoder. +- [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. +- [ ] 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 2 — engineering file formats + +- [ ] SCL object model. +- [ ] SCL parser and validation diagnostics. +- [ ] SCL workspace, profile extraction, and export. +- [ ] COMTRADE CFG/DAT reader. +- [ ] COMTRADE channel scaling and mapping. +- [ ] Expected-versus-observed engineering profiles. + +## Phase 3 — MMS transport and association + +- [ ] TPKT framing. +- [ ] COTP connection and data TPDU. +- [ ] Presentation layer. +- [ ] ACSE association and release. +- [ ] MMS confirmed/unconfirmed PDU envelopes. +- [ ] Invoke-ID routing with one receive pump. +- [ ] Name lists and variable-access attributes. +- [ ] MMS read/write services. + +## Phase 4 — reporting, control, and file service + +- [ ] DataSet and RCB inventory. +- [ ] Buffered and unbuffered report parsing. +- [ ] Report subscription, GI, and persistent monitoring. +- [ ] Direct and SBO control state machines. +- [ ] Enhanced-security CommandTermination and LastApplError. +- [ ] Cancel, timeout, disconnect, and cleanup behavior. +- [ ] MMS file directory and fault-record transfer. + +## Phase 5 — simulator and applications + +- [ ] Deterministic IED simulation runtime. +- [ ] Read-only MMS server baseline. +- [ ] CLI parity. +- [ ] Windows Npcap transport abstraction. +- [ ] Native desktop UI architecture. +- [ ] Engineering Workbench UI. +- [ ] IED Simulator UI. +- [ ] Discovery and SV Publisher UI. + +## 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 for Phase 1A. +- [x] Windows MSVC build and CTest for Phase 1B. +- [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. +- [ ] 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. diff --git a/PORT_STATUS.md b/PORT_STATUS.md index 1f674dd16..2a0c44f34 100644 --- a/PORT_STATUS.md +++ b/PORT_STATUS.md @@ -1,49 +1,42 @@ # C++ Port Status -## Repository audit +## Current milestone: Phase 1B -- Original implementation: C# / .NET 8. -- C# source files: 384. -- Approximate C# source size: 66,024 lines across libraries, applications, and tests. -- Core protocol library: 227 files / approximately 39,062 lines. -- Existing C# tests: 90 files / approximately 12,003 lines. -- Desktop UI: WPF/XAML and therefore requires a UI rewrite rather than direct translation. +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. -## Conversion delivered through Phase 1A - -The port now covers the deterministic Phase 0 foundation plus the C# MMS data-value and UTC-time modules: - -| Original area | C++ target | Status | +| Original C# area | C++ target | Status | |---|---|---| -| `Asn1/Ber*` | `include/ariec61850/asn1`, `src/asn1` | Ported | -| `Ethernet/*` | `include/ariec61850/ethernet`, `src/ethernet` | Ported | -| `Capture/Pcap*` | `include/ariec61850/capture`, `src/capture` | Ported | -| `Goose/GooseRetransmissionSchedule` | `include/ariec61850/goose` | Ported | -| IEC 61850 UTC time and MMS Data values | `include/ariec61850/mms`, `src/mms` | Ported | -| GOOSE PDU | Phase 1B | Pending | -| Sampled Values codec and publisher core | Phase 1 | Pending | +| 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 client | Phase 3 | Pending | -| Reporting, control, file service | Phase 4 | Pending | -| Simulation, CLI parity, native desktop UI | Phase 5 | 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 | -## Validation performed +## Phase 1B validation -- GNU C++ 14.2, C++20, warnings as errors: passed. -- Clang 17, C++20, warnings as errors: passed. -- CTest regression executable: 12/12 checks passed under both compilers. -- C#-derived MMS golden vector: byte-for-byte match. -- GitHub Actions workflow added for GCC, Clang, and Windows MSVC. -- No external C++ runtime dependency is required through Phase 1A. +- 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. -The sandbox did not contain the .NET SDK, so the original C# solution could not be rebuilt -inside this environment. The ported regression vectors were derived directly from the -repository source and its existing C# tests. Before merging into the main branch, run the -existing `dotnet test` suite on a machine with .NET 8 and preserve its output as the C# -baseline oracle. +See `MIGRATION_CHECKLIST.md` for the complete roadmap and current checkboxes. ## Safety boundary -This phase contains no active IED control and no raw-Ethernet publishing session. Active -paths should only be enabled after cross-language equivalence, cancellation, timeout, -association-loss cleanup, and isolated-lab interoperability have been demonstrated. +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. diff --git a/README.md b/README.md index 5b4a9c0f6..0909098c1 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,39 @@ # ARIEC61850 C++ Port -This directory is the incremental C++20 migration of the existing ARIEC61850 C# stack. -The C# implementation remains the behavioral reference until every migrated module has -cross-language golden-vector coverage. - -## Implemented - -- CMake-based C++20 static library. -- BER TLV reader/writer and primitive encoders. -- MAC address, VLAN, Ethernet frame, and IEC 61850 process-bus header codecs. -- Classic PCAP Ethernet reader/writer. -- GOOSE retransmission schedule. -- Standalone regression tests derived from the C# test suite. -- IEC 61850 8-byte UTC time value with quality byte. -- Recursive MMS Data value model and BER codec for Array, Structure, Boolean, BitString, Integer, Unsigned, FloatingPoint, OctetString, VisibleString, BinaryTime, MmsString, UTC time, and unknown context-specific tags. -- GitHub Actions matrix for GCC, Clang, and Windows MSVC. +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. -## Build +## Implemented through Phase 1B -### Windows: Visual Studio 2022 +- 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. -```powershell -cd arstack61850 -cmake -S . -B build -G "Visual Studio 17 2022" -A x64 -cmake --build build --config Release -ctest --test-dir build -C Release --output-on-failure -``` +See [`MIGRATION_CHECKLIST.md`](MIGRATION_CHECKLIST.md) for detailed progress. -### Windows/Linux: Ninja +## Build ```bash -cd arstack61850 -cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -cmake --build build +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build --parallel ctest --test-dir build --output-on-failure ``` -## Migration rule +For Visual Studio multi-configuration builds: -Do not delete the C# module immediately after porting it. For each module: +```powershell +cmake -S . -B build -A x64 +cmake --build build --config Release --parallel +ctest --test-dir build -C Release --output-on-failure +``` -1. Capture deterministic C# input/output vectors. -2. Implement the same public behavior in C++. -3. Run byte-for-byte codec comparisons and semantic state-machine tests. -4. Validate with laboratory PCAP/SCL evidence. -5. Only then switch the application boundary to C++. +## Migration rule -Active control and publishing paths must remain disabled by default until the C++ path -has equivalent guardrails, cancellation, cleanup, and evidence handling. +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. diff --git a/include/ariec61850/goose/frame.hpp b/include/ariec61850/goose/frame.hpp new file mode 100644 index 000000000..8905470c6 --- /dev/null +++ b/include/ariec61850/goose/frame.hpp @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/goose/pdu.hpp" + +#include +#include + +namespace ar::iec61850::goose { + +struct GooseFrame final { + ethernet::MacAddress destination; + ethernet::MacAddress source; + std::optional vlan; + std::uint16_t app_id{}; + std::uint16_t reserved1{}; + std::uint16_t reserved2{}; + GoosePdu pdu; +}; + +} // namespace ar::iec61850::goose diff --git a/include/ariec61850/goose/frame_codec.hpp b/include/ariec61850/goose/frame_codec.hpp new file mode 100644 index 000000000..473f3cfdb --- /dev/null +++ b/include/ariec61850/goose/frame_codec.hpp @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "ariec61850/goose/frame.hpp" + +#include +#include +#include + +namespace ar::iec61850::goose { + +class GooseFrameCodec final { +public: + [[nodiscard]] static std::vector encode(const GooseFrame& frame); + [[nodiscard]] static bool try_decode( + std::span frame_bytes, GooseFrame& frame) noexcept; +}; + +} // namespace ar::iec61850::goose diff --git a/include/ariec61850/goose/pdu.hpp b/include/ariec61850/goose/pdu.hpp new file mode 100644 index 000000000..d0efbdc6f --- /dev/null +++ b/include/ariec61850/goose/pdu.hpp @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "ariec61850/mms/data_value.hpp" +#include "ariec61850/mms/utc_time.hpp" + +#include +#include +#include + +namespace ar::iec61850::goose { + +struct GoosePdu final { + std::string go_cb_ref; + std::uint32_t time_allowed_to_live_milliseconds{}; + std::string data_set_reference; + std::string go_id; + mms::Iec61850UtcTime timestamp{}; + std::uint32_t state_number{1U}; + std::uint32_t sequence_number{}; + bool test{}; + std::uint32_t configuration_revision{1U}; + bool needs_commissioning{}; + std::vector values; +}; + +} // namespace ar::iec61850::goose diff --git a/include/ariec61850/goose/pdu_codec.hpp b/include/ariec61850/goose/pdu_codec.hpp new file mode 100644 index 000000000..724bd38b5 --- /dev/null +++ b/include/ariec61850/goose/pdu_codec.hpp @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "ariec61850/goose/pdu.hpp" + +#include +#include +#include + +namespace ar::iec61850::goose { + +class GoosePduCodec final { +public: + [[nodiscard]] static std::vector encode(const GoosePdu& pdu); + [[nodiscard]] static bool try_decode( + std::span apdu, GoosePdu& pdu) noexcept; +}; + +} // namespace ar::iec61850::goose diff --git a/src/goose/frame_codec.cpp b/src/goose/frame_codec.cpp new file mode 100644 index 000000000..662e3c362 --- /dev/null +++ b/src/goose/frame_codec.cpp @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/goose/frame_codec.hpp" + +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/goose/pdu_codec.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::goose { + +std::vector GooseFrameCodec::encode(const GooseFrame& frame) { + const auto apdu = GoosePduCodec::encode(frame.pdu); + const auto ethernet_frame = ethernet::ProcessBusFrameCodec::encode_ethernet_frame( + frame.destination, + frame.source, + ethernet::goose_ethertype, + frame.app_id, + apdu, + frame.vlan, + frame.reserved1, + frame.reserved2); + return ethernet::EthernetFrameCodec::encode(ethernet_frame); +} + +bool GooseFrameCodec::try_decode( + const std::span frame_bytes, GooseFrame& frame) noexcept { + frame = {}; + + ethernet::EthernetFrame ethernet_frame; + if (!ethernet::EthernetFrameCodec::try_decode(frame_bytes, ethernet_frame) || + ethernet_frame.ether_type != ethernet::goose_ethertype) { + return false; + } + + ethernet::ProcessBusFrame process_bus; + if (!ethernet::ProcessBusFrameCodec::try_decode(ethernet_frame, process_bus)) { + return false; + } + + GoosePdu pdu; + if (!GoosePduCodec::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::goose diff --git a/src/goose/pdu_codec.cpp b/src/goose/pdu_codec.cpp new file mode 100644 index 000000000..15588f129 --- /dev/null +++ b/src/goose/pdu_codec.cpp @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/goose/pdu_codec.hpp" + +#include "ariec61850/asn1/ber.hpp" +#include "ariec61850/mms/data_codec.hpp" +#include "ariec61850/mms/utc_time.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::goose { +namespace { + +constexpr std::int32_t goose_pdu_application_tag = 1; + +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); +} + +} // namespace + +std::vector GoosePduCodec::encode(const GoosePdu& pdu) { + asn1::BerWriter content; + + const auto go_cb_ref = asn1::BerWriter::encode_ascii(pdu.go_cb_ref); + write_context_primitive(content, 0, go_cb_ref); + + const auto time_allowed = asn1::BerWriter::encode_unsigned_integer( + pdu.time_allowed_to_live_milliseconds); + write_context_primitive(content, 1, time_allowed); + + const auto data_set = asn1::BerWriter::encode_ascii(pdu.data_set_reference); + write_context_primitive(content, 2, data_set); + + const auto go_id = asn1::BerWriter::encode_ascii(pdu.go_id); + write_context_primitive(content, 3, go_id); + + const auto timestamp = pdu.timestamp.to_bytes(); + write_context_primitive(content, 4, timestamp); + + const auto state_number = asn1::BerWriter::encode_unsigned_integer(pdu.state_number); + write_context_primitive(content, 5, state_number); + + const auto sequence_number = asn1::BerWriter::encode_unsigned_integer(pdu.sequence_number); + write_context_primitive(content, 6, sequence_number); + + const auto test = asn1::BerWriter::encode_boolean(pdu.test); + write_context_primitive(content, 7, test); + + const auto configuration_revision = asn1::BerWriter::encode_unsigned_integer( + pdu.configuration_revision); + write_context_primitive(content, 8, configuration_revision); + + const auto needs_commissioning = asn1::BerWriter::encode_boolean( + pdu.needs_commissioning); + write_context_primitive(content, 9, needs_commissioning); + + const auto entry_count = asn1::BerWriter::encode_unsigned_integer( + static_cast(pdu.values.size())); + write_context_primitive(content, 10, entry_count); + + const auto all_data = mms::MmsDataCodec::encode_all(pdu.values); + content.write_tlv(asn1::BerClass::context_specific, true, 11, all_data); + + return asn1::BerWriter::encode_tlv( + asn1::BerClass::application, + true, + goose_pdu_application_tag, + content.bytes()); +} + +bool GoosePduCodec::try_decode( + const std::span apdu, GoosePdu& pdu) noexcept { + pdu = {}; + + try { + std::size_t offset = 0U; + asn1::BerTlv outer; + if (!asn1::BerReader::try_read_tlv(apdu, offset, outer) || + outer.tag_class != asn1::BerClass::application || + outer.tag_number != goose_pdu_application_tag || + !outer.constructed) { + return false; + } + + std::string go_cb_ref; + std::uint32_t time_allowed_to_live = 0U; + std::string data_set; + std::string go_id; + mms::Iec61850UtcTime timestamp{}; + std::uint32_t state_number = 0U; + std::uint32_t sequence_number = 0U; + bool test = false; + std::uint32_t configuration_revision = 0U; + bool needs_commissioning = false; + std::uint32_t num_data_set_entries = 0U; + std::vector values; + + for (const auto& field : asn1::BerReader::read_children(outer.value)) { + if (field.tag_class != asn1::BerClass::context_specific) { + continue; + } + + switch (field.tag_number) { + case 0: + go_cb_ref = asn1::BerReader::read_ascii_string(field); + break; + case 1: + time_allowed_to_live = asn1::BerReader::read_uint32(field).value_or(0U); + break; + case 2: + data_set = asn1::BerReader::read_ascii_string(field); + break; + case 3: + go_id = asn1::BerReader::read_ascii_string(field); + break; + case 4: + timestamp = mms::Iec61850UtcTime::from_bytes(field.value); + break; + case 5: + state_number = asn1::BerReader::read_uint32(field).value_or(0U); + break; + case 6: + sequence_number = asn1::BerReader::read_uint32(field).value_or(0U); + break; + case 7: + test = asn1::BerReader::read_boolean(field).value_or(false); + break; + case 8: + configuration_revision = asn1::BerReader::read_uint32(field).value_or(0U); + break; + case 9: + needs_commissioning = asn1::BerReader::read_boolean(field).value_or(false); + break; + case 10: + num_data_set_entries = asn1::BerReader::read_uint32(field).value_or(0U); + break; + case 11: + values = mms::MmsDataCodec::decode_all(field.value); + break; + default: + break; + } + } + + if (num_data_set_entries != 0U && + values.size() != static_cast(num_data_set_entries)) { + return false; + } + + pdu.go_cb_ref = std::move(go_cb_ref); + pdu.time_allowed_to_live_milliseconds = time_allowed_to_live; + pdu.data_set_reference = std::move(data_set); + pdu.go_id = std::move(go_id); + pdu.timestamp = timestamp; + pdu.state_number = state_number; + pdu.sequence_number = sequence_number; + pdu.test = test; + pdu.configuration_revision = configuration_revision; + pdu.needs_commissioning = needs_commissioning; + pdu.values = std::move(values); + return true; + } catch (...) { + pdu = {}; + return false; + } +} + +} // namespace ar::iec61850::goose diff --git a/tests/test_goose.cpp b/tests/test_goose.cpp new file mode 100644 index 000000000..6f55496ac --- /dev/null +++ b/tests/test_goose.cpp @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/goose/frame_codec.hpp" +#include "ariec61850/goose/pdu_codec.hpp" +#include "ariec61850/mms/data_value.hpp" +#include "ariec61850/mms/utc_time.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 length must be even."); + ByteVector result; + result.reserve(text.size() / 2U); + for (std::size_t index = 0; 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::goose::GoosePdu make_pdu() { + using namespace ar::iec61850; + goose::GoosePdu pdu; + pdu.go_cb_ref = "LD0/LLN0$GO$gcb1"; + pdu.time_allowed_to_live_milliseconds = 1000U; + pdu.data_set_reference = "LD0/LLN0$DS1"; + pdu.go_id = "GOOSE1"; + pdu.timestamp = mms::Iec61850UtcTime{ + std::chrono::system_clock::time_point{std::chrono::seconds{1'781'233'445}} + + std::chrono::milliseconds{250}, 0x0AU}; + pdu.state_number = 3U; + pdu.sequence_number = 9U; + pdu.test = false; + pdu.configuration_revision = 2U; + pdu.needs_commissioning = true; + pdu.values = { + mms::MmsDataValue::boolean(true), + mms::MmsDataValue::integer(-3), + mms::MmsDataValue::visible_string("OK")}; + return pdu; +} + +void pdu_matches_csharp_vector_and_roundtrips() { + using namespace ar::iec61850; + const auto encoded = goose::GoosePduCodec::encode(make_pdu()); + CHECK(to_hex(encoded) == + "615480104C44302F4C4C4E3024474F2467636231810203E8820C4C44302F4C4C4E3024445331" + "8306474F4F53453184086A2B77254000000A8501038601098701008801028901018A0103AB0A" + "8301018501FD8A024F4B"); + + goose::GoosePdu decoded; + CHECK(goose::GoosePduCodec::try_decode(encoded, decoded)); + CHECK(decoded.go_cb_ref == "LD0/LLN0$GO$gcb1"); + CHECK(decoded.time_allowed_to_live_milliseconds == 1000U); + CHECK(decoded.data_set_reference == "LD0/LLN0$DS1"); + CHECK(decoded.go_id == "GOOSE1"); + CHECK(decoded.state_number == 3U); + CHECK(decoded.sequence_number == 9U); + CHECK(!decoded.test); + CHECK(decoded.configuration_revision == 2U); + CHECK(decoded.needs_commissioning); + CHECK(decoded.values.size() == 3U); + CHECK(std::get(decoded.values[0].value())); + CHECK(std::get(decoded.values[1].value()) == -3); + CHECK(std::get(decoded.values[2].value()) == "OK"); +} + +void ethernet_frame_matches_vector_and_roundtrips() { + 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 = make_pdu(); + + const auto encoded = goose::GooseFrameCodec::encode(frame); + CHECK(to_hex(encoded) == + "010CCD0100010200000000018100806488B81001005E00000000615480104C44302F4C4C4E30" + "24474F2467636231810203E8820C4C44302F4C4C4E30244453318306474F4F53453184086A2B" + "77254000000A8501038601098701008801028901018A0103AB0A8301018501FD8A024F4B"); + + goose::GooseFrame decoded; + CHECK(goose::GooseFrameCodec::try_decode(encoded, decoded)); + CHECK(decoded.destination == frame.destination); + CHECK(decoded.source == frame.source); + CHECK(decoded.vlan.has_value()); + CHECK(decoded.vlan.value() == frame.vlan.value()); + CHECK(decoded.app_id == 0x1001U); + CHECK(decoded.pdu.sequence_number == 9U); + CHECK(decoded.pdu.values.size() == 3U); +} + +void mismatched_data_set_count_is_rejected() { + using namespace ar::iec61850; + auto encoded = goose::GoosePduCodec::encode(make_pdu()); + const ByteVector marker{0x8AU, 0x01U, 0x03U}; + const auto position = std::search(encoded.begin(), encoded.end(), marker.begin(), marker.end()); + CHECK(position != encoded.end()); + *(position + 2) = 0x04U; + goose::GoosePdu decoded; + CHECK(!goose::GoosePduCodec::try_decode(encoded, decoded)); +} + +void malformed_pdu_and_wrong_ethertype_are_rejected() { + using namespace ar::iec61850; + goose::GoosePdu decoded_pdu; + CHECK(!goose::GoosePduCodec::try_decode(from_hex("610584036A2B77"), decoded_pdu)); + CHECK(!goose::GoosePduCodec::try_decode(from_hex("3000"), decoded_pdu)); + + 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 = make_pdu(); + auto bytes = goose::GooseFrameCodec::encode(frame); + bytes[16] = 0x88U; + bytes[17] = 0xBAU; + goose::GooseFrame decoded_frame; + CHECK(!goose::GooseFrameCodec::try_decode(bytes, decoded_frame)); +} +} + +int main() { + const std::vector>> tests{ + {"GOOSE PDU golden vector", pdu_matches_csharp_vector_and_roundtrips}, + {"GOOSE Ethernet golden vector", ethernet_frame_matches_vector_and_roundtrips}, + {"GOOSE dataset count validation", mismatched_data_set_count_is_rejected}, + {"GOOSE malformed input", malformed_pdu_and_wrong_ethertype_are_rejected}}; + 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 tests.\n"; + return 0; +}