Repository navigation
feat(M4-P0B): three-stream binary V2 envelope with bounded CRC and atomic decode #335
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| // SPDX-License-Identifier: GPL-3.0-or-later | ||
| #pragma once | ||
|
|
||
| #include "ariec61850/sampled_values/m4_three_sv_roster.hpp" | ||
|
|
||
| #include <array> | ||
| #include <cstddef> | ||
| #include <cstdint> | ||
| #include <optional> | ||
| #include <span> | ||
|
|
||
| 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<std::size_t> encoded_size( | ||
| const std::array<CompiledSvDeviceProfile, m4_three_sv_capacity>& profiles) noexcept; | ||
|
|
||
| [[nodiscard]] static M4ThreeSvBinaryResult encode_into( | ||
| const std::array<CompiledSvDeviceProfile, m4_three_sv_capacity>& profiles, | ||
| std::span<std::uint8_t> destination) noexcept; | ||
|
|
||
| [[nodiscard]] static M4ThreeSvBinaryResult decode( | ||
| std::span<const std::uint8_t> source, | ||
| std::array<CompiledSvDeviceProfile, m4_three_sv_capacity>& destination) noexcept; | ||
| }; | ||
|
|
||
| } // namespace ar::iec61850::sampled_values |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <array> | ||
| #include <cstddef> | ||
| #include <cstdint> | ||
| #include <limits> | ||
| #include <span> | ||
| #include <string_view> | ||
|
|
||
| namespace ar::iec61850::sampled_values { | ||
| namespace { | ||
| constexpr std::array<std::uint8_t, 4> kMagic{'A', 'R', 'M', '3'}; | ||
| constexpr std::size_t kCrcOffset = 12U; | ||
| constexpr std::size_t kLengthOffset = 20U; | ||
|
|
||
| void write_u16(const std::span<std::uint8_t> data, | ||
| const std::size_t pos, const std::uint16_t value) noexcept { | ||
| data[pos] = static_cast<std::uint8_t>(value >> 8U); | ||
| data[pos + 1U] = static_cast<std::uint8_t>(value); | ||
| } | ||
| void write_u32(const std::span<std::uint8_t> data, | ||
| const std::size_t pos, const std::uint32_t value) noexcept { | ||
| data[pos] = static_cast<std::uint8_t>(value >> 24U); | ||
| data[pos + 1U] = static_cast<std::uint8_t>(value >> 16U); | ||
| data[pos + 2U] = static_cast<std::uint8_t>(value >> 8U); | ||
| data[pos + 3U] = static_cast<std::uint8_t>(value); | ||
| } | ||
| [[nodiscard]] std::uint16_t read_u16(const std::span<const std::uint8_t> data, | ||
| const std::size_t pos) noexcept { | ||
| return static_cast<std::uint16_t>((static_cast<std::uint16_t>(data[pos]) << 8U) | | ||
| static_cast<std::uint16_t>(data[pos + 1U])); | ||
| } | ||
| [[nodiscard]] std::uint32_t read_u32(const std::span<const std::uint8_t> data, | ||
| const std::size_t pos) noexcept { | ||
| return (static_cast<std::uint32_t>(data[pos]) << 24U) | | ||
| (static_cast<std::uint32_t>(data[pos + 1U]) << 16U) | | ||
| (static_cast<std::uint32_t>(data[pos + 2U]) << 8U) | | ||
| static_cast<std::uint32_t>(data[pos + 3U]); | ||
| } | ||
| [[nodiscard]] std::uint32_t envelope_crc( | ||
| const std::span<const std::uint8_t> data) noexcept { | ||
| std::uint32_t state = integrity::crc32_initial_state; | ||
| state = integrity::crc32_update(state, data.first(kCrcOffset)); | ||
| constexpr std::array<std::uint8_t, 4> 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<CompiledSvDeviceProfile, m4_three_sv_capacity>& 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<std::size_t> M4ThreeSvBinaryCodec::encoded_size( | ||
| const std::array<CompiledSvDeviceProfile, m4_three_sv_capacity>& 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); | ||
|
Comment on lines
+96
to
+99
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When two input profiles have matching MAC/APPID/VLAN fields and equally oversized AGENTS.md reference: AGENTS.md:L57-L64 Useful? React with 👍 / 👎. |
||
| if (!size.has_value() || | ||
| *size == 0U || *size > std::numeric_limits<std::uint16_t>::max() || | ||
| total > std::numeric_limits<std::uint32_t>::max() - *size) { | ||
| return std::nullopt; | ||
| } | ||
| total += *size; | ||
| } | ||
| return total; | ||
| } | ||
|
|
||
| M4ThreeSvBinaryResult M4ThreeSvBinaryCodec::encode_into( | ||
| const std::array<CompiledSvDeviceProfile, m4_three_sv_capacity>& p, | ||
| const std::span<std::uint8_t> 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<std::uint8_t>(m4_three_sv_capacity); | ||
| out[7U] = 0U; // reserved | ||
| write_u32(out, 8U, static_cast<std::uint32_t>(*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<std::uint16_t>(*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<const std::uint8_t> source, | ||
| std::array<CompiledSvDeviceProfile, m4_three_sv_capacity>& 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<CompiledSvDeviceProfile, m4_three_sv_capacity> 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <array> | ||
| #include <cstddef> | ||
| #include <cstdint> | ||
| #include <filesystem> | ||
| #include <iostream> | ||
| #include <stdexcept> | ||
| #include <string> | ||
| #include <vector> | ||
|
|
||
| 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<CompiledSvDeviceProfile, m4_three_sv_capacity>; | ||
|
|
||
| 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<std::uint16_t>(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<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t> 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A roster containing sixteen INT32 leaves per stream passes both encode and decode successfully, despite having no Quality leaves: these count/size checks do not establish the required eight ordered INT32+Quality pairs. The nested V1 codec intentionally accepts broader layouts, whereas the existing deployment gates in
esp32p4_profile_support.hppandcompiled_profile_adapter.hppreject them, along with unsupported ASDU options. Consequently, the new envelope can publish a supposedly validated M4 roster that the device rejects. Reuse a shared device-capability validator so this boundary preserves the existing layout and option restrictions.AGENTS.md reference: AGENTS.md:L30-L31
Useful? React with 👍 / 👎.