diff --git a/CMakeLists.txt b/CMakeLists.txt index e6396c343..b3aa42634 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -335,6 +335,16 @@ if(ARIEC61850_BUILD_TESTS) add_test(NAME ariec61850_m4_three_sv_binary_tests COMMAND ariec61850_m4_three_sv_binary_tests) + add_executable(ariec61850_m4_multi_profile_staging_tests tests/test_m4_multi_profile_staging.cpp) + target_link_libraries(ariec61850_m4_multi_profile_staging_tests PRIVATE ARIEC61850::core) + target_compile_definitions(ariec61850_m4_multi_profile_staging_tests PRIVATE + ARIEC61850_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") + target_compile_features(ariec61850_m4_multi_profile_staging_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_m4_multi_profile_staging_tests) + ariec61850_apply_sanitizers(ariec61850_m4_multi_profile_staging_tests) + add_test(NAME ariec61850_m4_multi_profile_staging_tests + COMMAND ariec61850_m4_multi_profile_staging_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/docs/M4_P0C1_MULTISV_FIRMWARE_PROTOCOL.md b/docs/M4_P0C1_MULTISV_FIRMWARE_PROTOCOL.md new file mode 100644 index 000000000..7c05d98da --- /dev/null +++ b/docs/M4_P0C1_MULTISV_FIRMWARE_PROTOCOL.md @@ -0,0 +1,46 @@ +# M4-P0C1 — ESP32-P4 Multi-SV V2 Control Plane (Not Yet the TX Engine) + +**Status:** implementation on feature branch / PR under test. **Do not use M3.H.3 official firmware** for these commands; the new `MULTISV` protocol is available only in a firmware build from this change. + +## Design boundary + +The existing `PROFILE` command, single-stream 4I+4V SV publisher, live signal bank, autonomous board behavior, PTP task, and V1 START are **unchanged**. A new **control-plane-only** `MultiSvConfigStore` receives the M4 host compiler's `ARM3` V2 envelope and commits three validated profiles to a separate fixed-capacity RAM bank. This step **does not activate triple-stream Ethernet TX**. An operator may still use the existing single SV output through the old command set. + +Firmware identifies support using `MULTISV-CONFIG-STAGING-V2`. The capability `SV-MULTI3-V1` must **not** appear until M4-P0C2 actually implements and measures three on-wire canonical SV publishers. All `MULTISV STATUS/SHOW/COMMITTED` responses contain `output=INACTIVE_UNTIL_M4_P0C2`. + +## Serial command reference (192-byte ASCII command limit) + +```text +MULTISV STATUS +MULTISV SHOW +MULTISV BEGIN +MULTISV CHUNK +MULTISV VALIDATE +MULTISV COMMIT +MULTISV ABORT +``` + +Recommended Studio flow: + +1. Use **existing** `SclParser` + `M4ThreeSvRosterCompiler`: compile **exactly 3** explicit 9-2LE profiles for the provided SCD; all 3 must be supported 4I+4V, 4000 Hz, 64B, `nofASDU=1`, verified counter wrap 4000. +2. `M4ThreeSvBinaryCodec::encode_into()` writes a single `ARM3 V2` binary envelope with three existing nested `ARSV V1` records. Compute `sv_binary_profile_identity_hash` of the **entire** envelope, not three separately. +3. With SV **STOPPED**, call `BEGIN` with strictly increasing transaction ID and the exact V2 binary size. Send `CHUNK` bytes sequentially, offset zero first, no overlap/gaps/replay, max **48 bytes** per command, 96 hex characters. A malformed chunk automatically aborts the transfer. +4. `VALIDATE` only succeeds after receiving the entire correctly checksummed V2 payload and decoding **all three** profiles. It also checks shared 4000 Hz and distinct complete wire identities; the SCD's **repeated APPID 0x4000 on different MACs** is valid. +5. `COMMIT` replaces the stored control-plane roster **only after VALIDATE**. The previous committed roster is unchanged after missing/duplicate chunks, CRC error, missing VALIDATE, or failed COMMIT. `STATUS` exposes committed generation/hash/length; `SHOW` additionally prints all three MAC/svID/APPID/VLAN/rate/ASDU/configRevision entries for engineer review. +6. A standalone old single-stream `START` aborts incomplete `MULTISV` upload; the committed three-stream configuration remains inert. Closing Studio does **not** erase a committed roster in powered board RAM. A reboot resets the volatile roster and its transaction sequence. + +The console control task has 7168 bytes of stack in the current ESP-IDF build. The streaming buffer is statically allocated, as are candidate and committed rosters, with **no heap allocation or XML parsing** in the MCU control path. The full V2 decode uses preexisting firmware-safe V1 records and remains on the low-priority console control task, **never ISR/publisher**. + +## Files + +- `embedded/esp32p4_smv_injector/main/multi_profile_staging.hpp`: reusable bounded upload + all-or-nothing V2 validated commit. +- `embedded/esp32p4_smv_injector/main/multi_profile_control.cpp`: 192-byte legacy console parsing / response / STATUS / SHOW. +- `embedded/esp32p4_smv_injector/main/live_control.cpp`: command routing, capability tagging, V1 START transfer abort. +- `embedded/esp32p4_smv_injector/components/arstack_process_bus/CMakeLists.txt`: links the shared V2 codec into firmware. +- `tests/test_m4_multi_profile_staging.cpp`: GCC/Clang/MSVC host verification of real SCD-equivalent three-stream V2 upload and rollback. + +## Engineering acceptance and next gate + +- C++ CI must compile and run `ariec61850_m4_multi_profile_staging_tests` for GCC/Clang/MSVC; ESP32-P4 build must compile/link the new `MULTISV` command route and shared `m4_three_sv_binary.cpp`. +- No claim of three simultaneous Ethernet SV outputs, a shared measured PTP epoch, or three independent live injection setpoints is made at this checkpoint. +- **Next — P0C2:** connect an atomic committed triple roster to `SvStreamRuntime[3]` and one EMAC/PTP-anchored GPTimer. Implement the **three packet send loop** before advertising `SV-MULTI3-V1`, then implement per-slot bank/GUI and capture the 12,000 fps target. diff --git a/embedded/esp32p4_smv_injector/components/arstack_process_bus/CMakeLists.txt b/embedded/esp32p4_smv_injector/components/arstack_process_bus/CMakeLists.txt index 6c91cd0fe..1c4bc9c66 100644 --- a/embedded/esp32p4_smv_injector/components/arstack_process_bus/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/components/arstack_process_bus/CMakeLists.txt @@ -8,6 +8,7 @@ idf_component_register( "${ARSTACK_ROOT}/src/sampled_values/pdu_codec.cpp" "${ARSTACK_ROOT}/src/sampled_values/frame_codec.cpp" "${ARSTACK_ROOT}/src/sampled_values/compiled_device_profile.cpp" + "${ARSTACK_ROOT}/src/sampled_values/m4_three_sv_binary.cpp" "${ARSTACK_ROOT}/src/time_sync/ptp.cpp" INCLUDE_DIRS "${ARSTACK_ROOT}/include") diff --git a/embedded/esp32p4_smv_injector/main/CMakeLists.txt b/embedded/esp32p4_smv_injector/main/CMakeLists.txt index 520d53df3..85131c15d 100644 --- a/embedded/esp32p4_smv_injector/main/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/main/CMakeLists.txt @@ -12,6 +12,7 @@ idf_component_register( "ethernet_port.c" "live_control.cpp" "profile_control.cpp" + "multi_profile_control.cpp" "runtime_profile.cpp" "smp_synch_lab.cpp" ${AR_PTP_LAB_SRCS} diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 05cbadaf7..1fdda4d42 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -3,6 +3,7 @@ #include "live_control.hpp" #include "ethernet_port.h" #include "profile_control.hpp" +#include "multi_profile_control.hpp" #include "smp_synch_lab.hpp" #include "ariec61850/sampled_values/live_signal_transaction.hpp" @@ -254,6 +255,8 @@ void print_help() noexcept { ESP_LOGI(kTag, " PTP POLICY (PTP+SV must be STOPPED)"); ESP_LOGI(kTag, " PTP CONFIG [bench_global_0_1 UTC_epoch_seconds]"); ESP_LOGI(kTag, "GUI profile deployment uses bounded PROFILE subcommands while STOPPED."); + ESP_LOGI(kTag, " MULTISV STATUS|SHOW|BEGIN |CHUNK |VALIDATE |COMMIT |ABORT "); + ESP_LOGI(kTag, " MULTISV V2 is control-plane ONLY until M4-P0C2; it does not start three SV publishers."); } void print_identity() noexcept { @@ -266,10 +269,10 @@ void print_identity() noexcept { const char* version = app != nullptr ? app->version : "unknown"; #if CONFIG_AR_PTP_LAB_TX const char* capabilities = - "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-LAN-AUTONOMY-V1,SV-CONFIG-TXN-V1,PTP-P2,PTP-GM-BENCH-V1,PTP-POLICY-M3D-V1,SMPSYNCH-AUTO,PROFILE-BINARY-V1,PROFILE-REUSE-V1"; + "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-LAN-AUTONOMY-V1,SV-CONFIG-TXN-V1,PTP-P2,PTP-GM-BENCH-V1,PTP-POLICY-M3D-V1,SMPSYNCH-AUTO,PROFILE-BINARY-V1,PROFILE-REUSE-V1,MULTISV-CONFIG-STAGING-V2"; #else const char* capabilities = - "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-LAN-AUTONOMY-V1,SV-CONFIG-TXN-V1,PROFILE-BINARY-V1,PROFILE-REUSE-V1"; + "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-LAN-AUTONOMY-V1,SV-CONFIG-TXN-V1,PROFILE-BINARY-V1,PROFILE-REUSE-V1,MULTISV-CONFIG-STAGING-V2"; #endif ESP_LOGI(kTag, "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 device_id=%02X%02X%02X%02X%02X%02X firmware=%s build=%s boot_id=%016llX capabilities=%s", @@ -762,6 +765,9 @@ void handle_line(char* line) noexcept { return; } profile_control_abort_staging(); + // A V1 START invalidates any incomplete three-stream upload, but + // preserves a fully committed, inert M4 control-plane roster. + multi_profile_abort_staging(); // Serialize against the asynchronous Ethernet DOWN callback. A // link-down that wins this short critical section cannot be undone // by a late USB START on the other CPU. @@ -817,6 +823,11 @@ void handle_line(char* line) noexcept { print_help(); return; } + if (std::strcmp(command, "MULTISV") == 0) { + g_scenario_txn.reset(); + handle_multi_profile_command(save); + return; + } if (std::strcmp(command, "PROFILE") == 0) { g_scenario_txn.reset(); handle_profile_command(save); diff --git a/embedded/esp32p4_smv_injector/main/multi_profile_control.cpp b/embedded/esp32p4_smv_injector/main/multi_profile_control.cpp new file mode 100644 index 000000000..f92a5016e --- /dev/null +++ b/embedded/esp32p4_smv_injector/main/multi_profile_control.cpp @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "multi_profile_control.hpp" +#include "multi_profile_staging.hpp" +#include "live_control.hpp" + +#include "esp_log.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::esp32p4::smv { +namespace { +constexpr char kTag[] = "ar_smv_multi"; +constexpr char kDelimiters[] = " \t\r\n"; + +// One UART control-task owner; real-time publisher does NOT touch this bank +// until P0C2 adds a separately synchronized activation protocol. +MultiSvConfigStore g_multi{}; + +void upper(char* text) noexcept { + if (text == nullptr) return; + for (; *text; ++text) + *text = static_cast(std::toupper(static_cast(*text))); +} + +[[nodiscard]] bool parse_uint(const char* text, std::uint32_t& output) noexcept { + if (!text || !*text || *text == '-') return false; + errno = 0; + char* end = nullptr; + const auto parsed = std::strtoul(text, &end, 10); + if (errno || end == text || *end || parsed > std::numeric_limits::max()) + return false; + output = static_cast(parsed); + return true; +} + +[[nodiscard]] int nibble(const char ch) noexcept { + if (ch >= '0' && ch <= '9') return ch - '0'; + if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10; + if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10; + return -1; +} + +[[nodiscard]] bool decode_chunk( + const char* hex, + std::array& output, + std::size_t& count) noexcept { + count = 0U; + if (!hex) return false; + const auto length = std::strlen(hex); + if (!length || (length % 2U) || length > 2U * output.size()) return false; + for (std::size_t i = 0U; i < length; i += 2U) { + const int hi = nibble(hex[i]), lo = nibble(hex[i + 1U]); + if (hi < 0 || lo < 0) return false; + output[count++] = static_cast((hi << 4) | lo); + } + return true; +} + +[[nodiscard]] bool no_extra(char** save) noexcept { + return strtok_r(nullptr, kDelimiters, save) == nullptr; +} + +void print_status() noexcept { + ESP_LOGI(kTag, + "MULTISV STATUS staging=%u validated=%u transaction=%lu received=%lu expected=%lu last=%lu configured=%u generation=%llu hash=%016llX bytes=%lu output=INACTIVE_UNTIL_M4_P0C2", + g_multi.uploading() ? 1U : 0U, + g_multi.validated() ? 1U : 0U, + static_cast(g_multi.transaction()), + static_cast(g_multi.received()), + static_cast(g_multi.expected()), + static_cast(g_multi.last_transaction()), + g_multi.configured() ? 1U : 0U, + static_cast(g_multi.generation()), + static_cast(g_multi.committed_hash()), + static_cast(g_multi.committed_bytes())); +} + +void print_roster() noexcept { + print_status(); + if (!g_multi.configured()) return; + const auto& profiles = g_multi.committed_profiles(); + for (std::size_t i = 0U; i < profiles.size(); ++i) { + const auto& p = profiles[i]; + ESP_LOGI(kTag, + "MULTISV STREAM slot=%u svID=%s APPID=0x%04X MAC=%02X:%02X:%02X:%02X:%02X:%02X VLAN=%u/%u/%u rate=%lu wrap=%u noASDU=%u confRev=%lu output=INACTIVE", + static_cast(i + 1U), + p.sv_id.data(), + static_cast(p.app_id), + static_cast(p.destination_mac[0]), + static_cast(p.destination_mac[1]), + static_cast(p.destination_mac[2]), + static_cast(p.destination_mac[3]), + static_cast(p.destination_mac[4]), + static_cast(p.destination_mac[5]), + p.vlan_present ? 1U : 0U, + static_cast(p.vlan_id), + static_cast(p.vlan_priority), + static_cast(p.frame_rate_hz), + static_cast(p.sample_counter_modulus), + static_cast(p.no_asdu), + static_cast(p.configuration_revision)); + } +} +} // namespace + +void multi_profile_abort_staging() noexcept { g_multi.abort(); } + +void handle_multi_profile_command(char* arguments) noexcept { + char* save = nullptr; + char* command = strtok_r(arguments, kDelimiters, &save); + if (!command) { + ESP_LOGE(kTag, "MULTISV subcommand required; use STATUS/SHOW/BEGIN/CHUNK/VALIDATE/COMMIT/ABORT"); + return; + } + upper(command); + + if (!std::strcmp(command, "STATUS") || !std::strcmp(command, "SHOW")) { + if (!no_extra(&save)) { + ESP_LOGE(kTag, "MULTISV SHOW/STATUS takes no arguments"); + return; + } + if (!std::strcmp(command, "SHOW")) print_roster(); + else print_status(); + return; + } + + std::uint32_t txn{}; + if (!parse_uint(strtok_r(nullptr, kDelimiters, &save), txn) || txn == 0U) { + ESP_LOGE(kTag, "MULTISV rejected: missing or invalid transaction ID"); + return; + } + if (!std::strcmp(command, "ABORT")) { + if (!no_extra(&save) || !g_multi.owns(txn)) { + ESP_LOGE(kTag, "MULTISV ABORT rejected: unknown transaction"); + return; + } + g_multi.abort(); + ESP_LOGI(kTag, "MULTISV ABORT transaction=%lu accepted", static_cast(txn)); + return; + } + + // The old V1 publisher is never silently upgraded to a three-stream TX + // runtime. M4.0 identity/layout mutations require a STOPPED board. + if (live_tx_running()) { + g_multi.abort(); + ESP_LOGE(kTag, "MULTISV rejected: STOP_SV_FIRST"); + return; + } + + if (!std::strcmp(command, "BEGIN")) { + std::uint32_t total{}; + if (!parse_uint(strtok_r(nullptr, kDelimiters, &save), total) || + !no_extra(&save) || !g_multi.begin(txn, total)) { + ESP_LOGE(kTag, "MULTISV BEGIN rejected: busy, replay or invalid record size"); + return; + } + ESP_LOGI(kTag, "MULTISV BEGIN transaction=%lu bytes=%lu accepted", + static_cast(txn), static_cast(total)); + return; + } + if (!std::strcmp(command, "CHUNK")) { + std::uint32_t offset{}; + const char* offset_text = strtok_r(nullptr, kDelimiters, &save); + const char* chunk_text = strtok_r(nullptr, kDelimiters, &save); + std::array buffer{}; + std::size_t count{}; + if (!parse_uint(offset_text, offset) || !no_extra(&save) || + !g_multi.owns(txn) || !decode_chunk(chunk_text, buffer, count) || + !g_multi.append(txn, offset, std::span{buffer}.first(count))) { + g_multi.abort(); + ESP_LOGE(kTag, "MULTISV CHUNK rejected: invalid hex, offset or transaction"); + return; + } + ESP_LOGI(kTag, "MULTISV CHUNK transaction=%lu received=%lu", + static_cast(txn), static_cast(g_multi.received())); + return; + } + if (!std::strcmp(command, "VALIDATE")) { + if (!no_extra(&save) || !g_multi.validate(txn)) { + ESP_LOGE(kTag, "MULTISV VALIDATE rejected: incomplete, bad CRC or unsupported stream layout"); + return; + } + ESP_LOGI(kTag, "MULTISV VALIDATE transaction=%lu accepted count=3 rate=4000", + static_cast(txn)); + return; + } + if (!std::strcmp(command, "COMMIT")) { + if (!no_extra(&save) || !g_multi.commit(txn)) { + ESP_LOGE(kTag, "MULTISV COMMIT rejected: VALIDATE required or transaction invalid"); + return; + } + ESP_LOGI(kTag, + "MULTISV COMMITTED transaction=%lu generation=%llu hash=%016llX bytes=%lu output=INACTIVE_UNTIL_M4_P0C2", + static_cast(txn), + static_cast(g_multi.generation()), + static_cast(g_multi.committed_hash()), + static_cast(g_multi.committed_bytes())); + return; + } + ESP_LOGE(kTag, "Unknown MULTISV subcommand"); +} + +} // namespace ar::esp32p4::smv diff --git a/embedded/esp32p4_smv_injector/main/multi_profile_control.hpp b/embedded/esp32p4_smv_injector/main/multi_profile_control.hpp new file mode 100644 index 000000000..b2e287a1c --- /dev/null +++ b/embedded/esp32p4_smv_injector/main/multi_profile_control.hpp @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +namespace ar::esp32p4::smv { +void handle_multi_profile_command(char* arguments) noexcept; +void multi_profile_abort_staging() noexcept; +} diff --git a/embedded/esp32p4_smv_injector/main/multi_profile_staging.hpp b/embedded/esp32p4_smv_injector/main/multi_profile_staging.hpp new file mode 100644 index 000000000..973c0f599 --- /dev/null +++ b/embedded/esp32p4_smv_injector/main/multi_profile_staging.hpp @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/m4_three_sv_binary.hpp" +#include "ariec61850/sampled_values/profile_identity.hpp" +#include "binary_profile_staging.hpp" + +#include +#include +#include +#include +#include + +namespace ar::esp32p4::smv { + +// Control-plane ONLY until M4-P0C2 connects this roster to the shared +// PTP-grid publisher. Never pretend that COMMIT starts three Ethernet streams. +// The board holds two bounded banks: staged bytes and committed profiles; +// no XML, dynamic memory, or reallocations on ESP32-P4. +class MultiSvConfigStore final { +public: + using DeviceProfile = ar::iec61850::sampled_values::CompiledSvDeviceProfile; + using Profiles = std::array< + DeviceProfile, ar::iec61850::sampled_values::m4_three_sv_capacity>; + + static constexpr std::size_t max_chunk_bytes = BinaryProfileStaging::max_chunk_bytes; + static constexpr std::size_t min_record_bytes = + ar::iec61850::sampled_values::M4ThreeSvBinaryCodec::header_bytes + + ar::iec61850::sampled_values::m4_three_sv_capacity * + BinaryProfileStaging::min_record_bytes; + static constexpr std::size_t max_record_bytes = + ar::iec61850::sampled_values::M4ThreeSvBinaryCodec::header_bytes + + ar::iec61850::sampled_values::m4_three_sv_capacity * + BinaryProfileStaging::max_record_bytes; + static_assert(max_record_bytes < 4096U); + static_assert(20U + 10U + 1U + 10U + 1U + 2U * max_chunk_bytes < 192U); + + [[nodiscard]] bool begin(const std::uint32_t transaction, + const std::size_t total) noexcept { + if (uploading_ || transaction == 0U || transaction <= last_transaction_ || + total < min_record_bytes || total > max_record_bytes) return false; + staging_bytes_.fill(0U); + expected_ = total; + received_ = 0U; + transaction_ = transaction; + last_transaction_ = transaction; // reject replay even after aborted transfer + uploading_ = true; + validated_ = false; + return true; + } + + [[nodiscard]] bool owns(const std::uint32_t transaction) const noexcept { + return uploading_ && transaction != 0U && transaction == transaction_; + } + + [[nodiscard]] bool append(const std::uint32_t transaction, + const std::size_t offset, + const std::span bytes) noexcept { + if (!owns(transaction) || offset != received_ || bytes.empty() || + bytes.size() > max_chunk_bytes || + bytes.size() > expected_ - received_ || validated_) { + abort(); + return false; + } + std::copy(bytes.begin(), bytes.end(), staging_bytes_.begin() + received_); + received_ += bytes.size(); + return true; + } + + [[nodiscard]] bool complete(const std::uint32_t transaction) const noexcept { + return owns(transaction) && received_ == expected_; + } + + [[nodiscard]] bool validate(const std::uint32_t transaction) noexcept { + validated_ = false; + if (!complete(transaction)) return false; + const auto decoded = ar::iec61850::sampled_values::M4ThreeSvBinaryCodec::decode( + std::span{staging_bytes_}.first(expected_), + candidate_); + validated_ = decoded.success() && decoded.bytes == expected_; + return validated_; + } + + [[nodiscard]] bool commit(const std::uint32_t transaction) noexcept { + if (!complete(transaction) || !validated_) return false; + const auto bytes = std::span{staging_bytes_}.first(expected_); + // Whole-roster candidate was decoded AND cross-validated by the V2 codec. + // Publishing task does not yet read this bank: no partial-on-wire mutation. + committed_profiles_ = candidate_; + committed_hash_ = + ar::iec61850::sampled_values::sv_binary_profile_identity_hash(bytes); + committed_bytes_ = static_cast(bytes.size()); + ++generation_; + configured_ = true; + abort(); + return true; + } + + void abort() noexcept { + staging_bytes_.fill(0U); + expected_ = 0U; + received_ = 0U; + transaction_ = 0U; + uploading_ = false; + validated_ = false; + // Committed bank and last transaction persist across aborted uploads. + } + + [[nodiscard]] bool uploading() const noexcept { return uploading_; } + [[nodiscard]] bool validated() const noexcept { return validated_; } + [[nodiscard]] bool configured() const noexcept { return configured_; } + [[nodiscard]] std::uint32_t transaction() const noexcept { return transaction_; } + [[nodiscard]] std::uint32_t last_transaction() const noexcept { return last_transaction_; } + [[nodiscard]] std::size_t received() const noexcept { return received_; } + [[nodiscard]] std::size_t expected() const noexcept { return expected_; } + [[nodiscard]] std::uint64_t generation() const noexcept { return generation_; } + [[nodiscard]] std::uint64_t committed_hash() const noexcept { return committed_hash_; } + [[nodiscard]] std::uint32_t committed_bytes() const noexcept { return committed_bytes_; } + [[nodiscard]] const Profiles& committed_profiles() const noexcept { + return committed_profiles_; + } + +private: + std::array staging_bytes_{}; + Profiles candidate_{}; + Profiles committed_profiles_{}; + std::size_t expected_{}; + std::size_t received_{}; + std::uint32_t transaction_{}; + std::uint32_t last_transaction_{}; + std::uint32_t committed_bytes_{}; + std::uint64_t generation_{}; + std::uint64_t committed_hash_{}; + bool uploading_{}; + bool validated_{}; + bool configured_{}; +}; + +} // namespace ar::esp32p4::smv diff --git a/src/sampled_values/m4_three_sv_binary.cpp b/src/sampled_values/m4_three_sv_binary.cpp index 8ab7acfe5..678035e6d 100644 --- a/src/sampled_values/m4_three_sv_binary.cpp +++ b/src/sampled_values/m4_three_sv_binary.cpp @@ -60,7 +60,28 @@ void write_u32(const std::span data, p.no_asdu == 1U && p.leaf_count == 16U && p.payload_size_bytes == 64U && - p.asdu_options.sample_synchronized; + p.asdu_options.sample_synchronized && + !p.asdu_options.refresh_time && + !p.asdu_options.security && + !p.asdu_options.synch_source_id && + [&p]() noexcept { + // The ESP32-P4 runtime patches eight exactly ordered + // INT32+Quality pairs; 16 arbitrary four-byte leaves are NOT + // an interchangeable 4I+4V waveform payload. + for (std::size_t channel = 0U; channel < 8U; ++channel) { + const auto& value = p.leaves[channel * 2U]; + const auto& quality = p.leaves[channel * 2U + 1U]; + if (value.wire_type != SvDeviceWireType::int32 || + value.wire_width_bytes != 4U || value.quality || + value.timestamp || + quality.wire_type != SvDeviceWireType::quality || + quality.wire_width_bytes != 4U || !quality.quality || + quality.timestamp) { + return false; + } + } + return true; + }(); } [[nodiscard]] bool distinct_identity( diff --git a/tests/test_m4_multi_profile_staging.cpp b/tests/test_m4_multi_profile_staging.cpp new file mode 100644 index 000000000..0c4559896 --- /dev/null +++ b/tests/test_m4_multi_profile_staging.cpp @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "../embedded/esp32p4_smv_injector/main/multi_profile_staging.hpp" +#include "ariec61850/sampled_values/m4_three_sv_roster.hpp" +#include "ariec61850/sampled_values/m4_three_sv_binary.hpp" +#include "ariec61850/sampled_values/profile_identity.hpp" +#include "ariec61850/scl/parser.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +#define CHECK(condition) do { if (!(condition)) { \ + throw std::runtime_error(std::string{"M4 C1 CHECK failed: "} + #condition + \ + " at " + std::to_string(__LINE__)); \ +} } while (false) + +using ar::esp32p4::smv::MultiSvConfigStore; +using namespace ar::iec61850::sampled_values; + +std::vector sample_v2_roster() { + auto document = ar::iec61850::scl::SclParser{}.load( + std::filesystem::path{ARIEC61850_SOURCE_DIR} / + "tests/fixtures/scl/m4-three-9-2le-reference.scd"); + 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()); + const auto& s = compiled.roster->streams; + const MultiSvConfigStore::Profiles profiles{ + s[0].device_profile, s[1].device_profile, s[2].device_profile}; + const auto size = M4ThreeSvBinaryCodec::encoded_size(profiles); + CHECK(size.has_value()); + CHECK(*size >= MultiSvConfigStore::min_record_bytes); + CHECK(*size <= MultiSvConfigStore::max_record_bytes); + std::vector data(*size); + CHECK(M4ThreeSvBinaryCodec::encode_into(profiles, data).success()); + return data; +} + +void send_chunks(MultiSvConfigStore& store, const std::uint32_t transaction, + const std::vector& data) { + std::size_t offset = 0U; + while (offset < data.size()) { + const auto count = std::min( + MultiSvConfigStore::max_chunk_bytes, data.size() - offset); + CHECK(store.append(transaction, offset, + std::span{data}.subspan(offset, count))); + offset += count; + } +} + +void three_stream_firmware_config_lifecycle() { + MultiSvConfigStore store; + const auto data = sample_v2_roster(); + CHECK(!store.configured()); + CHECK(!store.validate(1U)); + CHECK(!store.commit(1U)); + CHECK(!store.begin(0U, data.size())); + CHECK(!store.begin(1U, MultiSvConfigStore::max_record_bytes + 1U)); + CHECK(store.begin(1U, data.size())); + CHECK(!store.begin(2U, data.size())); + send_chunks(store, 1U, data); + CHECK(store.complete(1U)); + CHECK(store.validate(1U)); + CHECK(store.validated()); + CHECK(store.commit(1U)); + CHECK(!store.uploading()); + CHECK(store.configured()); + CHECK(store.generation() == 1U); + CHECK(store.last_transaction() == 1U); + CHECK(store.committed_bytes() == data.size()); + CHECK(store.committed_hash() == + sv_binary_profile_identity_hash(std::span{data})); + const auto original = store.committed_profiles(); + CHECK(original[0].app_id == 0x4000U); + CHECK(original[1].app_id == 0x4000U); + CHECK(original[2].app_id == 0x4000U); + CHECK(original[0].destination_mac != original[1].destination_mac); + CHECK(original[1].destination_mac != original[2].destination_mac); + CHECK(original[0].frame_rate_hz == 4000U); + CHECK(original[2].sample_counter_modulus == 4000U); + + // The prior commit is untouched by an aborted, incomplete, or invalid V2. + CHECK(!store.begin(1U, data.size())); // replay blocked + CHECK(store.begin(2U, data.size())); + CHECK(store.append(2U, 0U, + std::span{data}.first(48U))); + CHECK(!store.validate(2U)); + CHECK(!store.commit(2U)); + store.abort(); + CHECK(store.configured() && store.generation() == 1U); + CHECK(store.committed_profiles() == original); + + CHECK(store.begin(3U, data.size())); + auto corrupt = data; + corrupt[corrupt.size() - 1U] ^= 0x01U; + send_chunks(store, 3U, corrupt); + CHECK(!store.validate(3U)); // aggregate CRC32 + CHECK(!store.commit(3U)); + CHECK(store.committed_profiles() == original); + CHECK(store.generation() == 1U); + store.abort(); + + CHECK(store.begin(4U, data.size())); + CHECK(!store.append(4U, 5U, std::span{data}.first(10U))); + CHECK(!store.uploading()); // bad chunk aborts the transaction + CHECK(store.committed_profiles() == original); + + CHECK(store.begin(5U, data.size())); + send_chunks(store, 5U, data); + CHECK(store.validate(5U)); + CHECK(store.commit(5U)); + CHECK(store.generation() == 2U); + CHECK(store.committed_profiles() == original); + CHECK(!store.begin(5U, data.size())); + std::cout << "PASS M4-P0C1: three V2 SV profiles, ordered 48-byte chunks, " + "atomic commit/readback, rollback, CRC and replay protection\n"; +} +} // namespace + +int main() { + try { + three_stream_firmware_config_lifecycle(); + return 0; + } catch (const std::exception& exception) { + std::cerr << exception.what() << '\n'; + return 1; + } +} diff --git a/tests/test_m4_three_sv_binary.cpp b/tests/test_m4_three_sv_binary.cpp index 8b13c7a4d..63a3a192d 100644 --- a/tests/test_m4_three_sv_binary.cpp +++ b/tests/test_m4_three_sv_binary.cpp @@ -93,6 +93,16 @@ void three_v1_profiles_inside_v2_envelope() { auto wrong_rate = profiles; wrong_rate[1].frame_rate_hz = 4800U; CHECK(!M4ThreeSvBinaryCodec::encoded_size(wrong_rate).has_value()); + // A 64-byte, 16-leaf DataSet is not sufficient: MU waveform output needs + // eight exact INT32/Quality pairs in the expected positional order. + auto wrong_leaf = profiles; + wrong_leaf[1].leaves[0].wire_type = SvDeviceWireType::uint32; + CHECK(!M4ThreeSvBinaryCodec::encoded_size(wrong_leaf).has_value()); + auto wrong_quality = profiles; + wrong_quality[2].leaves[1].wire_type = SvDeviceWireType::uint32; + wrong_quality[2].leaves[1].quality = false; + CHECK(!M4ThreeSvBinaryCodec::encoded_size(wrong_quality).has_value()); + std::cout << "PASS: V2 three-stream envelope: CRC32, all-or-none " "readback, V1 nested profiles, replay-safe byte format\n";