diff --git a/CMakeLists.txt b/CMakeLists.txt index 0d2aee725..e6396c343 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -51,6 +51,7 @@ add_library(ariec61850_core src/sampled_values/frame_codec.cpp src/sampled_values/compiled_device_profile.cpp src/sampled_values/m4_three_sv_roster.cpp + src/sampled_values/m4_three_sv_binary.cpp src/sampled_values/quality.cpp src/sampled_values/payload_inspector.cpp src/sampled_values/sample_counter.cpp @@ -324,6 +325,16 @@ if(ARIEC61850_BUILD_TESTS) add_test(NAME ariec61850_m4_three_sv_roster_tests COMMAND ariec61850_m4_three_sv_roster_tests) + add_executable(ariec61850_m4_three_sv_binary_tests tests/test_m4_three_sv_binary.cpp) + target_link_libraries(ariec61850_m4_three_sv_binary_tests PRIVATE ARIEC61850::core) + target_compile_definitions(ariec61850_m4_three_sv_binary_tests PRIVATE + ARIEC61850_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") + target_compile_features(ariec61850_m4_three_sv_binary_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_m4_three_sv_binary_tests) + ariec61850_apply_sanitizers(ariec61850_m4_three_sv_binary_tests) + add_test(NAME ariec61850_m4_three_sv_binary_tests + COMMAND ariec61850_m4_three_sv_binary_tests) + add_executable(ariec61850_simulation_tests tests/test_simulation.cpp) target_link_libraries(ariec61850_simulation_tests PRIVATE ARIEC61850::core) target_compile_definitions(ariec61850_simulation_tests PRIVATE diff --git a/include/ariec61850/sampled_values/m4_three_sv_binary.hpp b/include/ariec61850/sampled_values/m4_three_sv_binary.hpp new file mode 100644 index 000000000..46bc29ff7 --- /dev/null +++ b/include/ariec61850/sampled_values/m4_three_sv_binary.hpp @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/m4_three_sv_roster.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +// Distinct from the existing single-publisher 'ARSV' V1 codec. +// The V2 envelope nests exactly THREE complete, byte-bounded ARSV V1 records. +// No C++ object-layout serialization, XML, dynamic allocation, or optional +// unbounded stream count on the device receiving path. +inline constexpr std::uint16_t m4_three_sv_binary_version = 2U; + +enum class M4ThreeSvBinaryStatus : std::uint8_t { + ok, + buffer_too_small, + invalid_magic, + unsupported_version, + invalid_length, + checksum_mismatch, + invalid_profile, +}; + +struct M4ThreeSvBinaryResult final { + M4ThreeSvBinaryStatus status{M4ThreeSvBinaryStatus::invalid_profile}; + std::size_t bytes{}; + [[nodiscard]] bool success() const noexcept { + return status == M4ThreeSvBinaryStatus::ok; + } +}; + +class M4ThreeSvBinaryCodec final { +public: + static constexpr std::size_t header_bytes = 28U; + + // The caller owns and bounds every buffer. encode_into writes exactly the + // required number of bytes; decode commits the destination only on success. + [[nodiscard]] static std::optional encoded_size( + const std::array& profiles) noexcept; + + [[nodiscard]] static M4ThreeSvBinaryResult encode_into( + const std::array& profiles, + std::span destination) noexcept; + + [[nodiscard]] static M4ThreeSvBinaryResult decode( + std::span source, + std::array& destination) noexcept; +}; + +} // namespace ar::iec61850::sampled_values diff --git a/src/sampled_values/m4_three_sv_binary.cpp b/src/sampled_values/m4_three_sv_binary.cpp new file mode 100644 index 000000000..8ab7acfe5 --- /dev/null +++ b/src/sampled_values/m4_three_sv_binary.cpp @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/sampled_values/m4_three_sv_binary.hpp" +#include "ariec61850/integrity/crc32.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { +namespace { +constexpr std::array kMagic{'A', 'R', 'M', '3'}; +constexpr std::size_t kCrcOffset = 12U; +constexpr std::size_t kLengthOffset = 20U; + +void write_u16(const std::span data, + const std::size_t pos, const std::uint16_t value) noexcept { + data[pos] = static_cast(value >> 8U); + data[pos + 1U] = static_cast(value); +} +void write_u32(const std::span data, + const std::size_t pos, const std::uint32_t value) noexcept { + data[pos] = static_cast(value >> 24U); + data[pos + 1U] = static_cast(value >> 16U); + data[pos + 2U] = static_cast(value >> 8U); + data[pos + 3U] = static_cast(value); +} +[[nodiscard]] std::uint16_t read_u16(const std::span data, + const std::size_t pos) noexcept { + return static_cast((static_cast(data[pos]) << 8U) | + static_cast(data[pos + 1U])); +} +[[nodiscard]] std::uint32_t read_u32(const std::span data, + const std::size_t pos) noexcept { + return (static_cast(data[pos]) << 24U) | + (static_cast(data[pos + 1U]) << 16U) | + (static_cast(data[pos + 2U]) << 8U) | + static_cast(data[pos + 3U]); +} +[[nodiscard]] std::uint32_t envelope_crc( + const std::span data) noexcept { + std::uint32_t state = integrity::crc32_initial_state; + state = integrity::crc32_update(state, data.first(kCrcOffset)); + constexpr std::array zero{}; + state = integrity::crc32_update(state, zero); + state = integrity::crc32_update(state, data.subspan(kCrcOffset + zero.size())); + return integrity::crc32_finalize(state); +} + +[[nodiscard]] bool compatible( + const CompiledSvDeviceProfile& p) noexcept { + return (p.profile_family == SvProfileFamily::legacy_9_2le || + p.profile_family == SvProfileFamily::iec61850_9_2) && + p.frame_rate_hz == m4_three_sv_rate_hz && + p.configured_sample_rate == m4_three_sv_rate_hz && + p.sampling_basis == SvSampleMode::samples_per_second && + p.sample_counter_modulus == m4_three_sv_rate_hz && + p.no_asdu == 1U && + p.leaf_count == 16U && + p.payload_size_bytes == 64U && + p.asdu_options.sample_synchronized; +} + +[[nodiscard]] bool distinct_identity( + const CompiledSvDeviceProfile& lhs, + const CompiledSvDeviceProfile& rhs) noexcept { + const auto name = [](const CompiledSvDeviceProfile& p) { + return std::string_view{p.sv_id.data(), p.sv_id_length}; + }; + return !(lhs.app_id == rhs.app_id && + lhs.destination_mac == rhs.destination_mac && + lhs.transport_mode == rhs.transport_mode && + lhs.vlan_present == rhs.vlan_present && + (!lhs.vlan_present || + (lhs.vlan_id == rhs.vlan_id && + lhs.vlan_priority == rhs.vlan_priority)) && + name(lhs) == name(rhs)); +} + +[[nodiscard]] bool valid_roster( + const std::array& p) noexcept { + for (std::size_t i = 0U; i < p.size(); ++i) { + if (!compatible(p[i])) return false; + for (std::size_t j = 0U; j < i; ++j) { + if (!distinct_identity(p[i], p[j])) return false; + } + } + return true; +} +} // namespace + +std::optional M4ThreeSvBinaryCodec::encoded_size( + const std::array& p) noexcept { + if (!valid_roster(p)) return std::nullopt; + std::size_t total = header_bytes; + for (const auto& profile : p) { + const auto size = SvDeviceProfileBinaryCodec::encoded_size(profile); + if (!size.has_value() || + *size == 0U || *size > std::numeric_limits::max() || + total > std::numeric_limits::max() - *size) { + return std::nullopt; + } + total += *size; + } + return total; +} + +M4ThreeSvBinaryResult M4ThreeSvBinaryCodec::encode_into( + const std::array& p, + const std::span destination) noexcept { + const auto total = encoded_size(p); + if (!total.has_value()) return {M4ThreeSvBinaryStatus::invalid_profile, 0U}; + if (destination.size() < *total) + return {M4ThreeSvBinaryStatus::buffer_too_small, *total}; + const auto out = destination.first(*total); + for (std::size_t i = 0U; i < kMagic.size(); ++i) out[i] = kMagic[i]; + write_u16(out, 4U, m4_three_sv_binary_version); + out[6U] = static_cast(m4_three_sv_capacity); + out[7U] = 0U; // reserved + write_u32(out, 8U, static_cast(*total)); + write_u32(out, kCrcOffset, 0U); + write_u32(out, 16U, m4_three_sv_rate_hz); + out[26U] = 0U; + out[27U] = 0U; + std::size_t offset = header_bytes; + for (std::size_t i = 0U; i < p.size(); ++i) { + const auto size = SvDeviceProfileBinaryCodec::encoded_size(p[i]); + if (!size.has_value()) return {M4ThreeSvBinaryStatus::invalid_profile, 0U}; + write_u16(out, kLengthOffset + 2U * i, static_cast(*size)); + const auto encoded = SvDeviceProfileBinaryCodec::encode_into( + p[i], out.subspan(offset, *size)); + if (!encoded.success() || encoded.bytes != *size) + return {M4ThreeSvBinaryStatus::invalid_profile, 0U}; + offset += *size; + } + write_u32(out, kCrcOffset, envelope_crc(out)); + return {M4ThreeSvBinaryStatus::ok, *total}; +} + +M4ThreeSvBinaryResult M4ThreeSvBinaryCodec::decode( + const std::span source, + std::array& destination) noexcept { + if (source.size() < header_bytes) + return {M4ThreeSvBinaryStatus::invalid_length, 0U}; + for (std::size_t i = 0U; i < kMagic.size(); ++i) + if (source[i] != kMagic[i]) + return {M4ThreeSvBinaryStatus::invalid_magic, 0U}; + if (read_u16(source, 4U) != m4_three_sv_binary_version) + return {M4ThreeSvBinaryStatus::unsupported_version, 0U}; + if (source[6U] != m4_three_sv_capacity || source[7U] != 0U || + read_u32(source, 8U) != source.size() || + read_u32(source, 16U) != m4_three_sv_rate_hz || + source[26U] != 0U || source[27U] != 0U) { + return {M4ThreeSvBinaryStatus::invalid_length, 0U}; + } + std::size_t sum = header_bytes; + for (std::size_t i = 0U; i < m4_three_sv_capacity; ++i) { + const std::size_t record_length = read_u16(source, kLengthOffset + 2U * i); + if (record_length == 0U || + record_length > source.size() - sum) { + return {M4ThreeSvBinaryStatus::invalid_length, 0U}; + } + sum += record_length; + } + if (sum != source.size()) + return {M4ThreeSvBinaryStatus::invalid_length, 0U}; + if (read_u32(source, kCrcOffset) != envelope_crc(source)) + return {M4ThreeSvBinaryStatus::checksum_mismatch, 0U}; + + // Validate all nested V1 records and identities before publishing any + // externally-visible state. Failed decode never edits the caller's roster. + std::array candidate{}; + std::size_t offset = header_bytes; + for (std::size_t i = 0U; i < candidate.size(); ++i) { + const auto length = read_u16(source, kLengthOffset + 2U * i); + const auto inner = SvDeviceProfileBinaryCodec::decode( + source.subspan(offset, length), candidate[i]); + if (!inner.success() || inner.bytes != length) + return {M4ThreeSvBinaryStatus::invalid_profile, 0U}; + offset += length; + } + if (!valid_roster(candidate)) + return {M4ThreeSvBinaryStatus::invalid_profile, 0U}; + + destination = candidate; + return {M4ThreeSvBinaryStatus::ok, source.size()}; +} + +} // namespace ar::iec61850::sampled_values diff --git a/tests/test_m4_three_sv_binary.cpp b/tests/test_m4_three_sv_binary.cpp new file mode 100644 index 000000000..8b13c7a4d --- /dev/null +++ b/tests/test_m4_three_sv_binary.cpp @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/sampled_values/m4_three_sv_binary.hpp" +#include "ariec61850/scl/parser.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +#define CHECK(condition) do { if (!(condition)) { \ + throw std::runtime_error(std::string{"M4 binary CHECK failed: "} + #condition + \ + " at " + std::to_string(__LINE__)); \ +} } while (false) + +using namespace ar::iec61850::sampled_values; +using Profiles = std::array; + +Profiles reference_profiles() { + const auto path = std::filesystem::path{ARIEC61850_SOURCE_DIR} / + "tests/fixtures/scl/m4-three-9-2le-reference.scd"; + const auto document = ar::iec61850::scl::SclParser{}.load(path); + SvPublisherProfileCompileContext context; + context.profile_family = SvProfileFamily::legacy_9_2le; + context.sample_counter_modulus = static_cast(4000U); + const auto compiled = M4ThreeSvRosterCompiler::compile(document, context); + CHECK(compiled.ok()); + return {compiled.roster->streams[0].device_profile, + compiled.roster->streams[1].device_profile, + compiled.roster->streams[2].device_profile}; +} + +void three_v1_profiles_inside_v2_envelope() { + const auto profiles = reference_profiles(); + const auto required = M4ThreeSvBinaryCodec::encoded_size(profiles); + CHECK(required.has_value()); + CHECK(*required > M4ThreeSvBinaryCodec::header_bytes); + + std::vector too_small(*required - 1U); + const auto shortage = M4ThreeSvBinaryCodec::encode_into(profiles, too_small); + CHECK(shortage.status == M4ThreeSvBinaryStatus::buffer_too_small); + CHECK(shortage.bytes == *required); + + std::vector bytes(*required); + const auto encoded = M4ThreeSvBinaryCodec::encode_into(profiles, bytes); + CHECK(encoded.success()); + CHECK(encoded.bytes == *required); + CHECK(bytes[0] == 'A' && bytes[1] == 'R' && + bytes[2] == 'M' && bytes[3] == '3'); + CHECK(bytes[6] == 3U); + CHECK(bytes[16] == 0U && bytes[17] == 0U && + bytes[18] == 0x0FU && bytes[19] == 0xA0U); // 4000 big endian + + Profiles decoded{}; + CHECK(M4ThreeSvBinaryCodec::decode(bytes, decoded).success()); + CHECK(decoded == profiles); + + // Successful decode is repeatable and byte-stable. + std::vector reencoded(*required); + CHECK(M4ThreeSvBinaryCodec::encode_into(decoded, reencoded).success()); + CHECK(reencoded == bytes); + + // No partial mutation even when the last nested V1 record is damaged. + const auto original = decoded; + bytes.back() ^= 1U; + CHECK(M4ThreeSvBinaryCodec::decode(bytes, decoded).status == + M4ThreeSvBinaryStatus::checksum_mismatch); + CHECK(decoded == original); + bytes.back() ^= 1U; + + bytes[5] = 3U; // version 2 -> 3 + CHECK(M4ThreeSvBinaryCodec::decode(bytes, decoded).status == + M4ThreeSvBinaryStatus::unsupported_version); + CHECK(decoded == original); + bytes[5] = 2U; + + bytes[23] ^= 1U; // length of second nested record + CHECK(!M4ThreeSvBinaryCodec::decode(bytes, decoded).success()); + CHECK(decoded == original); + bytes[23] ^= 1U; + + // Repeated APPID is allowed but complete duplicate wire identity is not. + auto duplicate = profiles; + duplicate[2].destination_mac = duplicate[0].destination_mac; + duplicate[2].sv_id = duplicate[0].sv_id; + duplicate[2].sv_id_length = duplicate[0].sv_id_length; + CHECK(!M4ThreeSvBinaryCodec::encoded_size(duplicate).has_value()); + + auto wrong_rate = profiles; + wrong_rate[1].frame_rate_hz = 4800U; + CHECK(!M4ThreeSvBinaryCodec::encoded_size(wrong_rate).has_value()); + + std::cout << "PASS: V2 three-stream envelope: CRC32, all-or-none " + "readback, V1 nested profiles, replay-safe byte format\n"; +} +} // namespace + +int main() { + try { + three_v1_profiles_inside_v2_envelope(); + return 0; + } catch (const std::exception& e) { + std::cerr << e.what() << '\n'; + return 1; + } +}