From f6c2505db05fb1772390ef2b7b01c097dc9095f8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:08:46 +0700 Subject: [PATCH 01/27] feat(studio): declare canonical SV VLAN override for each SCL stream --- apps/arstack_studio/src/SclProfileModel.hpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/arstack_studio/src/SclProfileModel.hpp b/apps/arstack_studio/src/SclProfileModel.hpp index 8c52cb2ae..d3c78021e 100644 --- a/apps/arstack_studio/src/SclProfileModel.hpp +++ b/apps/arstack_studio/src/SclProfileModel.hpp @@ -65,6 +65,9 @@ class SclProfileModel : public QAbstractListModel { Q_INVOKABLE bool confirmNominalFrequencyHz(int frequencyHz); Q_INVOKABLE void clearNominalFrequency(); Q_INVOKABLE bool selectProfileFamily(const QString& familyKey); + // Operator L2 override is compiled into the SAME canonical device profile + // and binary transaction. It is never a display-only VLAN setting. + Q_INVOKABLE bool configureSelectedVlan(bool present, int vid, int pcp); signals: void sourceChanged(); @@ -85,6 +88,11 @@ class SclProfileModel : public QAbstractListModel { }; void installDocument(ar::iec61850::scl::SclDocument document, bool referenceTemplate); + struct VlanOverride final { + bool present{}; + std::uint16_t vid{}; + std::uint8_t pcp{}; + }; void rebuildRows(); [[nodiscard]] QVariantMap profileToVariantMap( const ar::iec61850::sampled_values::SvPublisherProfile& profile) const; @@ -93,6 +101,7 @@ class SclProfileModel : public QAbstractListModel { std::optional confirmedCounterModulus_; std::optional confirmedNominalFrequencyMilliHz_; std::vector profileFamilies_; + std::vector> vlanOverrides_; std::vector rows_; int selectedIndex_{-1}; QString fatalError_; From 4ce1001b2890af3dda4b0d029e16235af38c2148 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:08:51 +0700 Subject: [PATCH 02/27] feat(studio): per-stream VLAN editing survives SCL recompilation and enters binary profile --- apps/arstack_studio/src/SclProfileModel.cpp | 25 +++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/arstack_studio/src/SclProfileModel.cpp b/apps/arstack_studio/src/SclProfileModel.cpp index 79a2d622c..39dfd365f 100644 --- a/apps/arstack_studio/src/SclProfileModel.cpp +++ b/apps/arstack_studio/src/SclProfileModel.cpp @@ -234,6 +234,7 @@ void SclProfileModel::installDocument( document_->sampled_values_streams.size(), referenceTemplate ? SvProfileFamily::legacy_9_2le : SvProfileFamily::unspecified); + vlanOverrides_.assign(document_->sampled_values_streams.size(), std::nullopt); rows_.clear(); selectedIndex_ = -1; fatalError_.clear(); @@ -296,6 +297,7 @@ void SclProfileModel::clear() { confirmedCounterModulus_.reset(); confirmedNominalFrequencyMilliHz_.reset(); profileFamilies_.clear(); + vlanOverrides_.clear(); rows_.clear(); selectedIndex_ = -1; fatalError_.clear(); @@ -315,6 +317,22 @@ void SclProfileModel::selectStream(const int row) { emit selectedProfileChanged(); } +bool SclProfileModel::configureSelectedVlan(const bool present, const int vid, const int pcp) { + if (!document_.has_value() || selectedIndex_ < 0 || + selectedIndex_ >= static_cast(vlanOverrides_.size()) || + vid < 0 || vid > 4094 || pcp < 0 || pcp > 7) return false; + const auto index = static_cast(selectedIndex_); + if (index >= rows_.size() || !rows_[index].profile.has_value()) return false; + const auto& current = *rows_[index].profile; + if (current.vlan_present == present && + current.vlan_id == static_cast(vid) && + current.vlan_priority == static_cast(pcp)) return true; + vlanOverrides_[index] = VlanOverride{ + present, static_cast(vid), static_cast(pcp)}; + rebuildRows(); + return true; +} + bool SclProfileModel::confirmCounterModulus(const int modulus) { if (!document_.has_value() || modulus <= 0 || modulus > 65535) return false; confirmedCounterModulus_ = static_cast(modulus); @@ -403,6 +421,13 @@ void SclProfileModel::rebuildRows() { row.deviceSupport = QStringLiteral("blocked"); } else { row.profile = std::move(compiled.profile); + if (streamIndex < vlanOverrides_.size() && + vlanOverrides_[streamIndex].has_value()) { + const auto& override = *vlanOverrides_[streamIndex]; + row.profile->vlan_present = override.present; + row.profile->vlan_id = override.vid; + row.profile->vlan_priority = override.pcp; + } const auto& profile = *row.profile; row.compatibilityClass = profile.profile_family_resolution == SvProfileFamilyResolution::resolved && From 8897befa52d5faead155dd2abce7cef94b143e75 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:09:49 +0700 Subject: [PATCH 03/27] feat(studio): edit SV Ethernet VLAN PCP/VID per SCL stream and require deploy --- apps/arstack_studio/qml/ProfileInspector.qml | 74 ++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/apps/arstack_studio/qml/ProfileInspector.qml b/apps/arstack_studio/qml/ProfileInspector.qml index 61889cd37..c1a1155ae 100644 --- a/apps/arstack_studio/qml/ProfileInspector.qml +++ b/apps/arstack_studio/qml/ProfileInspector.qml @@ -261,6 +261,80 @@ SurfacePanel { } } + // Applies to the canonical SV binary profile, not just the UI. + // A fresh profile deployment is required before SV START. + ColumnLayout { + Layout.fillWidth: true + spacing: 5 + Label { + text: "SV VLAN · Ethernet output" + color: panel.theme.text + font.family: panel.uiFont + font.pixelSize: panel.theme.captionSize + font.weight: Font.DemiBold + } + RowLayout { + Layout.fillWidth: true + spacing: 5 + StudioDarkCheckBox { + id: svVlanEnabled + theme: panel.theme + uiFont: panel.uiFont + text: "802.1Q" + checked: Boolean(panel.profile.vlanPresent) + enabled: panel.session && panel.session.engineeringEditable + } + Label { text: "VID"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 10 } + NumericField { + id: svVlanVid + Layout.preferredWidth: 66 + compact: true + theme: panel.theme + monoFont: panel.monoFont + text: String(panel.profile.vlanId === undefined ? 0 : panel.profile.vlanId) + validator: IntValidator { bottom: 0; top: 4094 } + enabled: svVlanEnabled.checked && panel.session && panel.session.engineeringEditable + } + Label { text: "PCP"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 10 } + NumericField { + id: svVlanPcp + Layout.preferredWidth: 45 + compact: true + theme: panel.theme + monoFont: panel.monoFont + text: String(panel.profile.vlanPriority === undefined ? 4 : panel.profile.vlanPriority) + validator: IntValidator { bottom: 0; top: 7 } + enabled: svVlanEnabled.checked && panel.session && panel.session.engineeringEditable + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Apply" + enabled: panel.session && panel.session.engineeringEditable + onClicked: { + if (!svVlanVid.acceptableInput || !svVlanPcp.acceptableInput + || svVlanVid.text.length === 0 || svVlanPcp.text.length === 0 + || !panel.profiles.configureSelectedVlan( + svVlanEnabled.checked, Number(svVlanVid.text), + Number(svVlanPcp.text))) { + panel.controller.showMessage("SV VLAN must have VID 0-4094 and PCP 0-7.", true) + return + } + panel.controller.profileDirty = true + panel.controller.showMessage("SV VLAN updated in canonical profile. Deploy before START.", false) + } + } + } + Label { + Layout.fillWidth: true + text: "VID 0 = priority-tagged (PCP only). A change requires Deploy Profile." + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: panel.theme.captionSize + wrapMode: Text.WordWrap + } + } + ColumnLayout { visible: panel.issueItems.length > 0 Layout.fillWidth: true From 4f19e63b6b2edbb5b5894d4b3b4abf54431d5d59 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:12:28 +0700 Subject: [PATCH 04/27] feat(studio): expose canonical SV profile to PTP Expert --- apps/arstack_studio/qml/ConfigurationHub.qml | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/arstack_studio/qml/ConfigurationHub.qml b/apps/arstack_studio/qml/ConfigurationHub.qml index 9c52a6420..cc41ad14d 100644 --- a/apps/arstack_studio/qml/ConfigurationHub.qml +++ b/apps/arstack_studio/qml/ConfigurationHub.qml @@ -175,6 +175,7 @@ Item { theme: hub.theme controller: hub.controller device: hub.device + profiles: hub.profiles session: hub.session uiFont: hub.uiFont monoFont: hub.monoFont From f8343faf04f3a67de7485ca50fcc5048d7fbd970 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:12:35 +0700 Subject: [PATCH 05/27] feat(studio): independently set PTP VLAN or explicitly match SV VLAN from operator profile --- apps/arstack_studio/qml/PtpExpertPanel.qml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index 61a5885e8..2776d48b2 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -9,6 +9,7 @@ SurfacePanel { property var controller property var device property var session + property var profiles property string uiFont: "Inter" property string monoFont: "Inter" property var roleValues: ["SOURCE", "RECEIVER", "MONITOR"] @@ -51,6 +52,16 @@ SurfacePanel { panel.ptpProfile(panel.activeRole)) } + function matchSvVlan() { + if (!panel.profiles || !panel.profiles.hasProfiles) + return false + const sv = panel.profiles.selectedProfile + vlanCheck.checked = Boolean(sv.vlanPresent) + vlanField.text = String(sv.vlanId) + pcpField.text = String(sv.vlanPriority) + return true + } + function startRole(role) { if (!panel.session || !panel.device.ptpAvailable || !panel.device.deviceVerified || panel.device.ptpRunning || panel.startPending || panel.session.updatingFirmware) @@ -549,6 +560,16 @@ SurfacePanel { NumericField { id: vlanField; enabled: vlanCheck.checked; theme: panel.theme; monoFont: panel.uiFont; text: "0"; validator: IntValidator { bottom: 0; top: 4094 } } Label { text: "VLAN PCP"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } NumericField { id: pcpField; enabled: vlanCheck.checked; theme: panel.theme; monoFont: panel.uiFont; text: "4"; validator: IntValidator { bottom: 0; top: 7 } } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Match SV VLAN" + enabled: panel.profiles && panel.profiles.hasProfiles && + !panel.device.ptpRunning && !panel.startPending + onClicked: panel.matchSvVlan() + ToolTip.visible: hovered + ToolTip.text: "Copy current SV profile VLAN enable, VID and PCP into PTP settings" + } Label { visible: modeCombo.currentIndex === 0 Layout.columnSpan: 4 From f9c669254eabb0996b614852583ea74b5129b411 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:12:40 +0700 Subject: [PATCH 06/27] fix(studio): verify SV VLAN on canonical binary device profile before deployment --- apps/arstack_studio/src/DeviceController.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 942e1031e..024c71c7d 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -973,6 +973,9 @@ bool DeviceController::deployProfile(const QVariantMap& profile) { if (svId.isEmpty() || svId != profile.value(QStringLiteral("svId")).toString() || decoded.app_id != profile.value(QStringLiteral("appId")).toUInt() || + decoded.vlan_present != profile.value(QStringLiteral("vlanPresent")).toBool() || + decoded.vlan_id != profile.value(QStringLiteral("vlanId")).toUInt() || + decoded.vlan_priority != profile.value(QStringLiteral("vlanPriority")).toUInt() || decoded.frame_rate_hz != profile.value(QStringLiteral("publisherRate")).toULongLong() || decoded.sample_counter_modulus != profile.value(QStringLiteral("counterModulus")).toUInt() || decoded.configuration_revision != profile.value(QStringLiteral("confRev")).toULongLong()) { From abf80d3d262dcaf4bf3dedb9e7eaf0998de2af2a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:13:58 +0700 Subject: [PATCH 07/27] feat(ptp): typed isolated bench GM epoch and quality profile configuration --- embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp index d0766d7ed..37a9a44f0 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp @@ -46,6 +46,11 @@ typedef struct ar_ptp_lab_config_t { uint16_t offset_scaled_log_variance; uint8_t time_source; int16_t current_utc_offset; + // Isolated test-set mode: GNSS-like Grandmaster on the local EMAC clock. + // The PC supplies an approximate UTC epoch, not a physical GPS input. + bool bench_global_gm; + uint32_t bench_utc_epoch_seconds; + int64_t bench_epoch_received_mono_us; // P2 measured discipline thresholds. These are used only in TIME_RECEIVER. int64_t maximum_path_delay_ns; @@ -140,6 +145,10 @@ bool ar_ptp_lab_is_running(void); * becomes ready after START or after a clock/TX failure; not traceability. */ uint32_t ar_ptp_lab_source_generation(void); +/** True only for a fresh, active, bench-emulated GM with hardware PTP TX. + * The SV phase/MAC qualification is checked separately for smpSynch=2. */ +bool ar_ptp_lab_bench_global_ready(void); + /** Read-only SOURCE startup result. Persists after failed task teardown and * never changes clock authority, SV lock gating or transmission scheduling. */ const char* ar_ptp_source_last_start_reason(void); From 11b846a66d6f3c5d085ded58a591106796f12c08 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:14:58 +0700 Subject: [PATCH 08/27] feat(esp32p4): isolated GNSS-like bench GM wire qualities gated on EMAC epoch and fresh source --- .../main/ptp_lab_task.cpp | 55 ++++++++++++++++--- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index 34db58c09..b826bab26 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -132,6 +132,7 @@ std::atomic g_ptp_config_failure{PtpConfigFailure::none}; return "UNKNOWN"; } std::atomic_bool g_ptp_started{false}; +std::atomic_bool g_bench_global_active{false}; std::atomic_bool g_ptp_ready{false}; std::atomic g_ptp_last_announce_us{0LL}; std::atomic g_ptp_last_sync_pair_us{0LL}; @@ -194,6 +195,9 @@ void fill_kconfig_defaults(ar_ptp_lab_config_t& config) noexcept { static_cast(CONFIG_AR_PTP_OFFSET_SCALED_LOG_VARIANCE); config.time_source = static_cast(CONFIG_AR_PTP_TIME_SOURCE); config.current_utc_offset = static_cast(CONFIG_AR_PTP_CURRENT_UTC_OFFSET); + config.bench_global_gm = false; + config.bench_utc_epoch_seconds = 0U; + config.bench_epoch_received_mono_us = 0LL; const std::string configured_identity{CONFIG_AR_PTP_CLOCK_IDENTITY}; if (!configured_identity.empty()) { @@ -251,10 +255,18 @@ void fill_kconfig_defaults(ar_ptp_lab_config_t& config) noexcept { options.respond_to_peer_delay = config.respond_to_peer_delay; options.priority1 = config.priority1; options.priority2 = config.priority2; - options.clock_class = config.clock_class; - options.clock_accuracy = static_cast(config.clock_accuracy); - options.offset_scaled_log_variance = config.offset_scaled_log_variance; - options.time_source = static_cast(config.time_source); + // An isolated simulator is explicitly configured to *emit* the same + // clock-quality fields a GNSS GM would emit. Timing still comes from + // this ESP32-P4's own IEEE1588 MAC clock; it is never an external source. + options.clock_class = config.bench_global_gm && config.role == AR_PTP_ROLE_LAB_SOURCE + ? 6U : config.clock_class; + options.clock_accuracy = config.bench_global_gm && config.role == AR_PTP_ROLE_LAB_SOURCE + ? PtpClockAccuracy::within_1_us + : static_cast(config.clock_accuracy); + options.offset_scaled_log_variance = config.bench_global_gm && + config.role == AR_PTP_ROLE_LAB_SOURCE ? 0x4E5DU : config.offset_scaled_log_variance; + options.time_source = config.bench_global_gm && config.role == AR_PTP_ROLE_LAB_SOURCE + ? PtpTimeSource::gps : static_cast(config.time_source); options.current_utc_offset = config.current_utc_offset; std::string error; @@ -271,6 +283,14 @@ void fill_kconfig_defaults(ar_ptp_lab_config_t& config) noexcept { std::string& error) { // LAB_SOURCE has no external clock input or traceability evidence. // Reject source-quality advertising that would mislead an IED BMCA. + if (config.role == AR_PTP_ROLE_LAB_SOURCE && config.bench_global_gm && + (config.bench_utc_epoch_seconds < 1577836800U || + config.bench_epoch_received_mono_us <= 0LL || + esp_timer_get_time() < config.bench_epoch_received_mono_us || + esp_timer_get_time() - config.bench_epoch_received_mono_us > 30000000LL)) { + error = "BENCH GM requires a fresh UTC epoch from Studio (30-second lease)"; + return false; + } if (config.role == AR_PTP_ROLE_LAB_SOURCE && (config.clock_class != 248U || config.clock_accuracy != @@ -609,6 +629,7 @@ esp_err_t ptp_input_info( void finish_runtime(PtpLabContext& context) { g_ptp_accept_rx.store(false, std::memory_order_release); g_ptp_ready.store(false, std::memory_order_release); + g_bench_global_active.store(false, std::memory_order_release); g_ptp_last_announce_us.store(0LL, std::memory_order_release); g_ptp_last_sync_pair_us.store(0LL, std::memory_order_release); if (context.runtime.has_value()) { @@ -643,7 +664,15 @@ void ptp_lab_task(void* argument) { } const auto selected_ptp_config = selected_config(); const bool ptp_tai_epoch_seeded = seed_hardware_clock( - context.eth_handle, selected_ptp_config.current_utc_offset); + context.eth_handle, selected_ptp_config); + if (selected_ptp_config.bench_global_gm && !ptp_tai_epoch_seeded) { + g_source_start_reason.store( + PtpSourceStartReason::ieee1588_clock_unavailable, std::memory_order_release); + ESP_LOGE(kTag, "BENCH GM rejected: EMAC PTP/TAI seed not established"); + finish_runtime(context); + vTaskDelete(nullptr); + return; + } const auto mac_result = esp_eth_ioctl( context.eth_handle, @@ -675,8 +704,10 @@ void ptp_lab_task(void* argument) { #else constexpr bool kOperatorVerifiedTaiUtcOffset = false; #endif - profile->time_properties = conservative_lab_time_properties( - ptp_tai_epoch_seeded, kOperatorVerifiedTaiUtcOffset); + profile->time_properties = selected_ptp_config.bench_global_gm + ? ar::iec61850::time_sync::PtpTimeProperties{true, true, true, true} + : conservative_lab_time_properties( + ptp_tai_epoch_seeded, kOperatorVerifiedTaiUtcOffset); if (!profile->time_properties.ptp_timescale) { ESP_LOGW(kTag, "PTP timescale/UTC offset UNKNOWN: local RTC or " "TAI-UTC offset not independently verified; no traceability claim"); @@ -693,6 +724,9 @@ void ptp_lab_task(void* argument) { return; } + g_bench_global_active.store( + selected_ptp_config.bench_global_gm && ptp_tai_epoch_seeded, + std::memory_order_release); const auto options = context.runtime->options(); // Nonstandard bench cadences are explicitly opt-in, so expiry tracks the // larger validated interval. Default power-profile TTL = 3 seconds. @@ -879,6 +913,7 @@ void start_ptp_lab(const esp_eth_handle_t eth_handle) { portEXIT_CRITICAL(&g_control_mux); reset_live_status(); + g_bench_global_active.store(false, std::memory_order_release); g_ptp_ready.store(false, std::memory_order_release); g_ptp_last_announce_us.store(0LL, std::memory_order_release); g_ptp_last_sync_pair_us.store(0LL, std::memory_order_release); @@ -1038,6 +1073,12 @@ extern "C" const char* ar_ptp_source_last_start_reason(void) { ar::esp32p4::smv::g_source_start_reason.load(std::memory_order_acquire)); } +extern "C" bool ar_ptp_lab_bench_global_ready(void) { + return ar::esp32p4::smv::g_bench_global_active.load( + std::memory_order_acquire) && + ar::esp32p4::smv::source_has_fresh_evidence(); +} + extern "C" std::uint32_t ar_ptp_source_generation(void) { if (!ar::esp32p4::smv::ptp_lab_is_running()) return 0U; return ar::esp32p4::smv::g_ptp_source_generation.load( From c7e9e4f5f708342ed55ac2ab714ac3db7a307688 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:15:26 +0700 Subject: [PATCH 09/27] feat(ptp): monotonic compensated Studio UTC seed for emulated PTP/TAI Grandmaster --- .../main/ptp_lab_task.cpp | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index b826bab26..8d6d3f8c2 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -402,12 +402,36 @@ void disable_hardware_ptp(const esp_eth_handle_t eth_handle) noexcept { // PTP/TAI seconds. A plausible RTC is not an accuracy/traceability guarantee. [[nodiscard]] bool seed_hardware_clock( const esp_eth_handle_t eth_handle, - const std::int16_t configured_tai_utc_offset) noexcept { + const ar_ptp_lab_config_t& config) noexcept { eth_mac_time_t initial_time{}; - const std::time_t system_time = std::time(nullptr); + const std::int64_t now_mono_us = esp_timer_get_time(); + const bool bench = config.bench_global_gm; + if (bench && (config.bench_utc_epoch_seconds < 1577836800U || + config.bench_epoch_received_mono_us <= 0LL || + now_mono_us < config.bench_epoch_received_mono_us || + now_mono_us - config.bench_epoch_received_mono_us > 30000000LL)) { + ESP_LOGE(kTag, "BENCH GM epoch missing or expired before EMAC clock start"); + return false; + } + // The serial CONFIG event is the timebase seed: interpolate elapsed + // monotonic time before switching on the MAC. Every subsequent PTP + // and SV sample uses the SAME hardware PTP epoch, not the PC clock. + const std::uint64_t elapsed_us = bench + ? static_cast( + now_mono_us - config.bench_epoch_received_mono_us) + : 0ULL; + const std::int64_t utc_seconds = bench + ? static_cast(config.bench_utc_epoch_seconds) + + static_cast(elapsed_us / 1000000ULL) + : static_cast(std::time(nullptr)); const auto tai_seconds = ar::iec61850::time_sync::try_seed_ptp_tai_seconds( - static_cast(system_time), configured_tai_utc_offset); - if (tai_seconds.has_value()) initial_time.seconds = *tai_seconds; + utc_seconds, config.current_utc_offset); + if (tai_seconds.has_value()) { + initial_time.seconds = *tai_seconds; + initial_time.nanoseconds = bench + ? static_cast((elapsed_us % 1000000ULL) * 1000ULL) + : 0U; + } const auto result = esp_eth_ioctl( eth_handle, static_cast(ETH_MAC_ESP_CMD_S_PTP_TIME), &initial_time); From 6fc757cf6bf767f6b91b93532840a7a4be65e08e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:16:00 +0700 Subject: [PATCH 10/27] feat(firmware): transactional PTP CONFIG accepts explicit bench GM mode and host UTC epoch --- .../main/live_control.cpp | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 16df8d22d..8cac6e995 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -251,7 +251,7 @@ void print_help() noexcept { ESP_LOGI(kTag, " QUALITY "); ESP_LOGI(kTag, " SHAPE CT <0|1> "); ESP_LOGI(kTag, " PTP SHOW | PTP DIAG | PTP START | PTP STOP"); - ESP_LOGI(kTag, " PTP CONFIG "); + ESP_LOGI(kTag, " PTP CONFIG [bench_global_0_1 UTC_epoch_seconds]"); ESP_LOGI(kTag, "GUI profile deployment uses bounded PROFILE subcommands while STOPPED."); } @@ -429,6 +429,18 @@ void handle_ptp_command(char* save) noexcept { return; } } + bool bench_global = false; + std::uint32_t utc_epoch = 0U; + char* bench_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); + if (bench_text != nullptr) { + std::uint32_t bench = 0U; + if (!parse_u32(bench_text, bench) || bench > 1U || + !parse_u32(strtok_r(nullptr, kTokenDelimiters.data(), &save), utc_epoch)) { + ESP_LOGE(kTag, "PTP configuration rejected: expected bench mode and UTC epoch"); + return; + } + bench_global = bench != 0U; + } if (has_extra_token(&save) || values[0] > 255U || values[1] > 15U || values[2] > 1U || values[3] > 4094U || values[4] > 7U || values[5] < 100U || values[5] > 10000U || values[6] < 20U || values[6] > 5000U || values[7] > 1U) { @@ -448,12 +460,18 @@ void handle_ptp_command(char* save) noexcept { config.announce_interval_ms = values[5]; config.sync_interval_ms = values[6]; config.respond_to_peer_delay = values[7] != 0U; + config.bench_global_gm = bench_global; + config.bench_utc_epoch_seconds = bench_global ? utc_epoch : 0U; + config.bench_epoch_received_mono_us = bench_global ? esp_timer_get_time() : 0LL; if (!ar_ptp_lab_configure(&config)) { ESP_LOGE(kTag, "PTP configuration rejected: reason=%s", ar_ptp_lab_last_config_error()); return; } ESP_LOGI(kTag, "PTP configuration accepted"); + ESP_LOGI(kTag, "PTP GM emulation=%u UTC_seed=%lu", + config.bench_global_gm ? 1U : 0U, + static_cast(config.bench_utc_epoch_seconds)); print_ptp_state(); return; } From 2000194c0dbf917f16379ac276be9693b22d0a91 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:16:20 +0700 Subject: [PATCH 11/27] feat(sv): promote smpSynch 2 only with measured SV MAC lock and fresh bench PTP GM --- .../esp32p4_smv_injector/main/smp_synch_lab.cpp | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp b/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp index f349049d5..88630cd76 100644 --- a/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp +++ b/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp @@ -2,6 +2,12 @@ #include "smp_synch_lab.hpp" #include "ariec61850/time_sync/sv_ptp_epoch.hpp" +#if defined(ESP_PLATFORM) +#include "sdkconfig.h" +#if CONFIG_AR_PTP_LAB_TX +#include "ptp_lab_task.hpp" +#endif +#endif #include #include @@ -103,8 +109,15 @@ void record_transition(const SvSyncLossReason reason, // SOURCE has no receiver discipline object. Its local-sync claim // originates exclusively from the sample phase + canonical TX gate. // Global sync is reserved for measured external time traceability. - if (g_source_local_qualified.load(std::memory_order_acquire)) + if (g_source_local_qualified.load(std::memory_order_acquire)) { +#if defined(ESP_PLATFORM) && CONFIG_AR_PTP_LAB_TX + // The same measured phase/MAC gate qualifies both local and bench + // global simulation. A UI flag alone can never promote AUTO to 2. + if (ar_ptp_lab_bench_global_ready()) + return SmpSynchValue::global_synchronized; +#endif return SmpSynchValue::local_synchronized; + } return ar::iec61850::time_sync::qualified_sv_sync_value( measured_value(), g_sv_timeline_phase_qualified.load( std::memory_order_acquire)); From 95822916cc5b6f45f4e48846e2f07d4de03af73c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:16:35 +0700 Subject: [PATCH 12/27] feat(firmware): expose bench GM mode capability to native Studio --- embedded/esp32p4_smv_injector/main/live_control.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 8cac6e995..245d9fbe7 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -265,8 +265,8 @@ void print_identity() noexcept { const char* version = app != nullptr ? app->version : "unknown"; #if CONFIG_AR_PTP_LAB_TX const char* capabilities = g_control_lease_timer != nullptr - ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,SV-CONFIG-TXN-V1,PTP-P2,SMPSYNCH-AUTO,PROFILE-BINARY-V1,PROFILE-REUSE-V1" - : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-CONFIG-TXN-V1,PTP-P2,SMPSYNCH-AUTO,PROFILE-BINARY-V1,PROFILE-REUSE-V1"; + ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,SV-CONFIG-TXN-V1,PTP-P2,PTP-GM-BENCH-V1,SMPSYNCH-AUTO,PROFILE-BINARY-V1,PROFILE-REUSE-V1" + : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-CONFIG-TXN-V1,PTP-P2,PTP-GM-BENCH-V1,SMPSYNCH-AUTO,PROFILE-BINARY-V1,PROFILE-REUSE-V1"; #else const char* capabilities = g_control_lease_timer != nullptr ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,SV-CONFIG-TXN-V1,PROFILE-BINARY-V1,PROFILE-REUSE-V1" From 2f5ac6737b8f0e3f0802dce02f8db48018b67bf3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:18:05 +0700 Subject: [PATCH 13/27] feat(studio): explicit PTP Grandmaster simulation option for relay injection --- apps/arstack_studio/qml/PtpExpertPanel.qml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index 2776d48b2..2328ddc78 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -43,7 +43,8 @@ SurfacePanel { syncIntervalMs: role === "SOURCE" ? 1000 : Number(syncField.text), // IEC/IEEE 61850-9-3 SOURCE always requires Pdelay capability. // An optional bench-only mode must never be silently substituted. - respondToPeerDelay: role === "SOURCE" ? true : pdelayCheck.checked + respondToPeerDelay: role === "SOURCE" ? true : pdelayCheck.checked, + benchGlobalGm: role === "SOURCE" && benchGmCheck.checked } } @@ -285,6 +286,21 @@ SurfacePanel { } } + StudioDarkCheckBox { + id: benchGmCheck + Layout.fillWidth: true + visible: modeCombo.currentIndex === 0 + theme: panel.theme + uiFont: panel.uiFont + checked: true + text: "Relay test bench · Simulated GNSS PTP Grandmaster + measured SV phase lock" + enabled: panel.session && panel.device.ptpAvailable && + !panel.device.ptpRunning && !panel.startPending && + !panel.session.updatingFirmware + ToolTip.visible: hovered + ToolTip.text: "Isolated relay test network only. No GPS required; PTP and SV share ESP32-P4 EMAC timebase. smpSynch=2 only after real phase/MAC evidence." + } + // Simulation is intentionally simple: auto or explicit SV flag. // The firmware already marks overrides as simulated, independently // of PTP clock discipline and without pretending sample phase lock. From cb92851da29980b91224c8037f0207c93f87eaeb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:18:09 +0700 Subject: [PATCH 14/27] feat(studio): send fresh UTC-epoch BENCH GM PTP CONFIG to supporting firmware --- apps/arstack_studio/src/DeviceController.cpp | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 024c71c7d..a93558f7e 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -1356,11 +1356,17 @@ QString DeviceController::ptpConfigCommand(const QVariantMap& profile) { announce < 100U || sync < 20U) { return {}; } - return QStringLiteral("PTP CONFIG %1 %2 %3 %4 %5 %6 %7 %8") + QString command = QStringLiteral("PTP CONFIG %1 %2 %3 %4 %5 %6 %7 %8") .arg(domain).arg(transport) .arg(profile.value(QStringLiteral("vlanEnabled")).toBool() ? 1 : 0) .arg(vlan).arg(pcp).arg(announce).arg(sync) .arg(profile.value(QStringLiteral("respondToPeerDelay")).toBool() ? 1 : 0); + if (profile.value(QStringLiteral("benchGlobalGm")).toBool()) { + // Studio supplies a fresh host UTC epoch. The device then uses + // one EMAC IEEE1588 timebase for both PTP TX and SV sampling. + command += QStringLiteral(" 1 %1").arg(QDateTime::currentSecsSinceEpoch()); + } + return command; } void DeviceController::resetPtpStartup() noexcept { @@ -1431,6 +1437,13 @@ bool DeviceController::startPtpConfigured(const QString& requestedRole, setError(QStringLiteral("Connect a PTP-capable ESP32-P4 and stop timing before starting.")); return false; } + if (profile.value(QStringLiteral("benchGlobalGm")).toBool() && + (role != QStringLiteral("SOURCE") || + !identity_.capabilities.contains(QStringLiteral("PTP-GM-BENCH-V1"), + Qt::CaseInsensitive))) { + setError(QStringLiteral("Bench GM requires SOURCE and matched PTP-GM-BENCH-V1 firmware.")); + return false; + } const QString command = ptpConfigCommand(profile); if (command.isEmpty()) { const QString reason = QStringLiteral( From c267260d3c7c14649014314191d1cd31e60a0999 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:20:15 +0700 Subject: [PATCH 15/27] feat(sv): model optional IEC 61850 Ed2.1 gmIdentity synchronization source --- include/ariec61850/sampled_values/asdu.hpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/include/ariec61850/sampled_values/asdu.hpp b/include/ariec61850/sampled_values/asdu.hpp index aae458257..2222a55cc 100644 --- a/include/ariec61850/sampled_values/asdu.hpp +++ b/include/ariec61850/sampled_values/asdu.hpp @@ -4,6 +4,7 @@ #include "ariec61850/mms/utc_time.hpp" +#include #include #include #include @@ -22,6 +23,9 @@ struct SampledValueAsdu final { std::uint8_t sample_synchronization{0U}; std::optional sample_rate; std::optional sample_mode; + // IEC 61850-9-2 Ed.2.1: SynchSrcID/gmIdentity, eight bytes in the + // same network order as the PTP GrandmasterIdentity in Announce. + std::optional> gm_identity; std::vector sample_payload; friend bool operator==(const SampledValueAsdu&, const SampledValueAsdu&) = default; From 791636b28f331ebb2a1d172ee1dcbde4e0be435d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:20:20 +0700 Subject: [PATCH 16/27] feat(sv): encode/decode standard context tag 0x89 eight-byte PTP gmIdentity --- src/sampled_values/pdu_codec.cpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/sampled_values/pdu_codec.cpp b/src/sampled_values/pdu_codec.cpp index a8839970e..e09566155 100644 --- a/src/sampled_values/pdu_codec.cpp +++ b/src/sampled_values/pdu_codec.cpp @@ -125,6 +125,10 @@ constexpr std::uint8_t context_constructed_base = 0xA0U; !add_tlv(context_unsigned_size(*asdu.sample_mode))) { return std::nullopt; } + if (asdu.gm_identity.has_value() && + !add_tlv(context_bytes_size(asdu.gm_identity->size()))) { + return std::nullopt; + } return total; } @@ -287,6 +291,13 @@ void write_unsigned( destination, offset, 8U, *asdu.sample_mode, layout != nullptr ? &layout->sample_mode : nullptr); } + if (asdu.gm_identity.has_value()) { + write_tlv_header( + destination, offset, + static_cast(context_primitive_base | 9U), + asdu.gm_identity->size()); + write_bytes(destination, offset, *asdu.gm_identity); + } return true; } @@ -358,6 +369,11 @@ bool read_asdu( } asdu.sample_mode = static_cast(integer); break; + case 9: + if (field.value.size() != 8U) return false; + asdu.gm_identity.emplace(); + std::copy(field.value.begin(), field.value.end(), asdu.gm_identity->begin()); + break; default: break; } From 3371f1961f0c17d3c6832bf9b26edf5c0abec3e4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:21:12 +0700 Subject: [PATCH 17/27] feat(esp32p4): include matching PTP GrandmasterIdentity in SV Ed2.1 bench and follower frames --- .../esp32p4_smv_injector/main/app_main.cpp | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 2999de093..879762edd 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -7,6 +7,7 @@ #include "ariec61850/sampled_values/live_signal_state.hpp" #include "ariec61850/time_sync/sv_ptp_epoch.hpp" #include "ariec61850/time_sync/sv_ptp_clock_lease.hpp" +#include "ariec61850/time_sync/ptp.hpp" #include "driver/gptimer.h" #include "ethernet_port.h" @@ -314,6 +315,38 @@ SampledValuesFrame make_runtime_frame( } asdu.sample_payload = make_4i4v_payload(); +#if CONFIG_AR_PTP_LAB_TX + // IEC 61850-9-2 Ed2.1 optional gmIdentity / SynchSrcID. The eight + // bytes MUST equal the GrandmasterIdentity in Announce. On the + // simulated GM bench this is the board's own clock identity; in + // RECEIVER mode it is the measured selected external source. + // Keep legacy 9-2LE unchanged unless a bench GM is selected. + if (!diagnostic_mirror) { + ar_ptp_lab_config_t ptp_config{}; + ar_ptp_timing_authority_t authority{}; + if (ar_ptp_lab_get_config(&ptp_config) && + ar_ptp_lab_get_timing_authority(&authority) && + authority.clock_usable) { + if (ptp_config.role == AR_PTP_ROLE_LAB_SOURCE && + ptp_config.bench_global_gm) { + if (ptp_config.clock_identity_override) { + std::array identity{}; + std::copy_n(ptp_config.clock_identity, 8U, identity.begin()); + asdu.gm_identity = identity; + } else { + asdu.gm_identity = + ar::iec61850::time_sync::ptp_clock_identity_from_mac(source_mac); + } + } else if (ptp_config.role == AR_PTP_ROLE_TIME_RECEIVER && + authority.source_selected) { + std::array identity{}; + std::copy_n(authority.selected_source_clock_identity, 8U, identity.begin()); + asdu.gm_identity = identity; + } + } + } +#endif + SampledValuesFrame frame; frame.destination = MacAddress{std::span{profile.destination_mac}}; frame.source = MacAddress{std::span{source_mac}}; From dcb040253c37a98ca29805942dde56e01d861417 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:21:54 +0700 Subject: [PATCH 18/27] test(sv): Ed2.1 gmIdentity wire tag 0x89, roundtrip and malformed length --- tests/test_sampled_values.cpp | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_sampled_values.cpp b/tests/test_sampled_values.cpp index e37923575..ebf2e6014 100644 --- a/tests/test_sampled_values.cpp +++ b/tests/test_sampled_values.cpp @@ -638,11 +638,42 @@ void quality_and_generic_payload_diagnostics_preserve_wire_evidence() { CHECK(trailing.trailing_bytes == ByteVector({0xAAU, 0xBBU})); } +// Ed.2.1 gmIdentity is exactly context-specific primitive tag 9 (0x89), +// eight octets in PTP GrandmasterIdentity network byte order. +void sampled_values_gm_identity_matches_ptp_and_rejects_truncation() { + using namespace ar::iec61850::sampled_values; + auto asdu = make_reference_asdu(); + constexpr std::array gm{ + 0xE8U, 0xF6U, 0x0AU, 0xFFU, 0xFEU, 0xE0U, 0xC5U, 0x3FU}; + asdu.gm_identity = gm; + const auto encoded = SampledValuesPduCodec::encode(SampledValuesPdu{{asdu}}); + const std::array expected{ + 0x89U, 0x08U, 0xE8U, 0xF6U, 0x0AU, + 0xFFU, 0xFEU, 0xE0U, 0xC5U, 0x3FU}; + const auto tag = std::search( + encoded.begin(), encoded.end(), expected.begin(), expected.end()); + CHECK(tag != encoded.end()); + SampledValuesPdu decoded{}; + CHECK(SampledValuesPduCodec::try_decode(encoded, decoded)); + CHECK(decoded.asdus.size() == 1U); + CHECK(decoded.asdus[0].gm_identity.has_value()); + CHECK(*decoded.asdus[0].gm_identity == gm); + auto truncated_identity = encoded; + const auto tag_offset = static_cast(tag - encoded.begin()); + truncated_identity[tag_offset + 1U] = 7U; + CHECK(!SampledValuesPduCodec::try_decode(truncated_identity, decoded)); + asdu.gm_identity.reset(); + const auto legacy = SampledValuesPduCodec::encode(SampledValuesPdu{{asdu}}); + CHECK(std::search(legacy.begin(), legacy.end(), + expected.begin(), expected.end()) == legacy.end()); +} + } // namespace int main() { const std::vector>> tests{ {"SV PDU golden vector", sampled_values_pdu_matches_csharp_golden_vector}, + {"SV Ed2.1 GM source identity", sampled_values_gm_identity_matches_ptp_and_rejects_truncation}, {"SV Ethernet frame", sampled_values_frame_round_trips_vlan_process_bus_header}, {"SV malformed input", sampled_values_codec_handles_multiple_asdus_and_rejects_malformed_input}, {"SV compiled device profile binary", compiled_device_profile_binary_is_canonical_and_integrity_checked}, From 6baa66c51cb351397c51efa24c51c5d104861a46 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:25:40 +0700 Subject: [PATCH 19/27] test(studio): native canonical SV VLAN edit, binary profile, invalid input and reload regression --- apps/arstack_studio/src/main.cpp | 33 +++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index d893e1937..06850b283 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -8,6 +8,7 @@ #include "StudioClipboard.hpp" #include "StudioDeviceController.hpp" #include "SvCaptureController.hpp" +#include "ariec61850/sampled_values/compiled_device_profile.hpp" #include #include @@ -199,8 +200,38 @@ int checkReferenceTemplate(int argc, char* argv[]) { return 3; } + // An operator VLAN edit MUST change the binary sent to the ESP32, not + // only a QML label. It must survive an SCL timing recompile and reject + // invalid values without corrupting the active stream profile. + if (!profiles.configureSelectedVlan(true, 123, 5) || + profiles.configureSelectedVlan(true, 4095, 5) || + profiles.configureSelectedVlan(true, 123, 8) || + !profiles.confirmCounterModulus(4000)) { + qCritical().noquote() << "SV VLAN operator edit/recompile regression"; + return 4; + } + const auto edited = profiles.selectedProfile(); + const auto blob = edited.value(QStringLiteral("deviceProfileBinary")).toByteArray(); + ar::iec61850::sampled_values::CompiledSvDeviceProfile decoded{}; + const auto bytes = std::span{ + reinterpret_cast(blob.constData()), + static_cast(blob.size())}; + if (blob.isEmpty() || + !ar::iec61850::sampled_values::SvDeviceProfileBinaryCodec::decode(bytes, decoded).success() || + !decoded.vlan_present || decoded.vlan_id != 123U || decoded.vlan_priority != 5U || + edited.value(QStringLiteral("vlanId")).toInt() != 123 || + edited.value(QStringLiteral("vlanPriority")).toInt() != 5) { + qCritical().noquote() << "SV VLAN binary profile mismatch" << edited; + return 5; + } + if (!profiles.loadReferenceTemplate() || + profiles.selectedProfile().value(QStringLiteral("vlanId")).toInt() != 0 || + profiles.selectedProfile().value(QStringLiteral("vlanPriority")).toInt() != 4) { + qCritical().noquote() << "SV VLAN override leaked across reference reload"; + return 6; + } qInfo().noquote() - << "ARStack 4I+4V 9-2LE reference template: PASS · Class A · ready · 4000 fps · 64 B"; + << "ARStack 4I+4V 9-2LE reference: PASS · 4000 fps · SV VLAN edit/binary/reload"; return 0; } From 63ea71e9a27a9a2a9d3582a09ac7a5725b7a5022 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:26:43 +0700 Subject: [PATCH 20/27] fix(esp32): include portable PTP EUI-64 clock identity helper header --- embedded/esp32p4_smv_injector/main/app_main.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 879762edd..fd5969b30 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -7,7 +7,7 @@ #include "ariec61850/sampled_values/live_signal_state.hpp" #include "ariec61850/time_sync/sv_ptp_epoch.hpp" #include "ariec61850/time_sync/sv_ptp_clock_lease.hpp" -#include "ariec61850/time_sync/ptp.hpp" +#include "ariec61850/time_sync/ptp_runtime.hpp" #include "driver/gptimer.h" #include "ethernet_port.h" From ce79ef0bed73080c9719a09dcd702bb0572c7f2b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:26:49 +0700 Subject: [PATCH 21/27] fix(test): initialize optional gmIdentity in existing strict aggregate SV golden fixture --- tests/test_sampled_values.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_sampled_values.cpp b/tests/test_sampled_values.cpp index ebf2e6014..d384fdf26 100644 --- a/tests/test_sampled_values.cpp +++ b/tests/test_sampled_values.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -76,7 +77,8 @@ ar::iec61850::sampled_values::SampledValueAsdu make_reference_asdu() { 2U, std::uint16_t{4000U}, std::uint16_t{1U}, - from_hex("0000006400000001000000C800000003")}; + from_hex("0000006400000001000000C800000003"), + std::nullopt}; } void sampled_values_pdu_matches_csharp_golden_vector() { From 530d531abda77b74aadd8c48531300d28a1faa9f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:28:18 +0700 Subject: [PATCH 22/27] fix(studio): reload VLAN editor on active SCL stream change; avoid cross-stream stale value --- apps/arstack_studio/qml/ProfileInspector.qml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/arstack_studio/qml/ProfileInspector.qml b/apps/arstack_studio/qml/ProfileInspector.qml index c1a1155ae..dc6f9edd7 100644 --- a/apps/arstack_studio/qml/ProfileInspector.qml +++ b/apps/arstack_studio/qml/ProfileInspector.qml @@ -38,6 +38,17 @@ SurfacePanel { panel.controller.openEngineeringFile() } + // Switching SCL streams or recompiling the selected one must reload the + // editor from that stream. Never silently apply another stream's VLAN. + Connections { + target: panel.profiles + function onSelectedProfileChanged() { + svVlanEnabled.checked = Boolean(panel.profile.vlanPresent) + svVlanVid.text = String(panel.profile.vlanId === undefined ? 0 : panel.profile.vlanId) + svVlanPcp.text = String(panel.profile.vlanPriority === undefined ? 4 : panel.profile.vlanPriority) + } + } + ColumnLayout { anchors.fill: parent anchors.margins: panel.compact ? 11 : 14 From a1cf936f9a3c41a6100f07fb0fec253733946a3d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:29:52 +0700 Subject: [PATCH 23/27] fix(ptp): validate UTC seed freshness at source START, not role handoff --- embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index 8d6d3f8c2..a3cfcfe13 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -283,12 +283,13 @@ void fill_kconfig_defaults(ar_ptp_lab_config_t& config) noexcept { std::string& error) { // LAB_SOURCE has no external clock input or traceability evidence. // Reject source-quality advertising that would mislead an IED BMCA. + // A SOURCE role handoff may reuse a staged profile temporarily. + // Enforce the 30-second freshness lease at the actual EMAC START + // (seed_hardware_clock), not during a prior PTPROLE change. if (config.role == AR_PTP_ROLE_LAB_SOURCE && config.bench_global_gm && (config.bench_utc_epoch_seconds < 1577836800U || - config.bench_epoch_received_mono_us <= 0LL || - esp_timer_get_time() < config.bench_epoch_received_mono_us || - esp_timer_get_time() - config.bench_epoch_received_mono_us > 30000000LL)) { - error = "BENCH GM requires a fresh UTC epoch from Studio (30-second lease)"; + config.bench_epoch_received_mono_us <= 0LL)) { + error = "BENCH GM requires a Studio UTC epoch"; return false; } if (config.role == AR_PTP_ROLE_LAB_SOURCE && From 95e8c769e164975b21062edd4b2ae9c604f628e2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:30:44 +0700 Subject: [PATCH 24/27] test(ptp): GNSS-profile simulator Announce wire flags, class, accuracy and GM identity --- tests/test_ptp_power_profile.cpp | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_ptp_power_profile.cpp b/tests/test_ptp_power_profile.cpp index f1d10910d..4e5fae791 100644 --- a/tests/test_ptp_power_profile.cpp +++ b/tests/test_ptp_power_profile.cpp @@ -156,6 +156,27 @@ void check_priority_tagged_power_profile_wire() { REQUIRE(!PtpPublisherRuntime::validate_options(options, error)); } +void check_simulated_global_gm_ptp_wire_properties() { + auto options = make_source(); + options.clock_class = 6U; + options.clock_accuracy = PtpClockAccuracy::within_1_us; + options.time_source = PtpTimeSource::gps; + options.time_properties = PtpTimeProperties{true, true, true, true}; + PtpPublisherRuntime gm{options}; + REQUIRE(gm.start()); + const auto packet = gm.prepare_announce(PtpTimestamp{1780000037U, 0U}); + REQUIRE(packet.has_value()); + PtpFrame frame; + REQUIRE(PtpCodec::try_parse_ethernet_frame(packet->ethernet_frame, frame)); + REQUIRE(frame.announce.has_value()); + REQUIRE(frame.announce->clock_class == 6U); + REQUIRE(frame.announce->clock_accuracy == PtpClockAccuracy::within_1_us); + REQUIRE(frame.announce->time_source == PtpTimeSource::gps); + REQUIRE(frame.announce->grandmaster_identity == options.clock_identity); + REQUIRE((frame.header.flags & 0x003CU) == 0x003CU); + gm.stop(); +} + void check_ptp_wire_sequence_and_identity() { auto options = make_source(); constexpr std::uint8_t domain = 93U; // supported alternate network domain @@ -334,6 +355,7 @@ int main() { check_source_readiness_requires_fresh_wire_proof(); check_source_heartbeat_freshness_and_time_provenance(); check_ptp_wire_sequence_and_identity(); + check_simulated_global_gm_ptp_wire_properties(); check_priority_tagged_power_profile_wire(); check_vlan_encoding_remains_configurable(); check_tai_seed_and_truthful_time_properties(); From d56f0f622a0f44e88861d32f6ff3ca45f0997dab Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:31:44 +0700 Subject: [PATCH 25/27] fix(studio): include span for native VLAN binary regression --- apps/arstack_studio/src/main.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 06850b283..1eb412506 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -35,6 +35,7 @@ #include #include #include +#include #include #ifndef ARSTACK_STUDIO_VERSION From 94b8c1458551974aa2ab0c44853ba06a431d061f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:31:50 +0700 Subject: [PATCH 26/27] fix(studio): gate advanced BENCH GM configuration on verified compatible firmware --- apps/arstack_studio/src/DeviceController.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index a93558f7e..d472a4b7a 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -1691,6 +1691,13 @@ bool DeviceController::configurePtp(const QVariantMap& profile) { setError(QStringLiteral("Stop PTP and finish pending operations before changing settings.")); return false; } + if (profile.value(QStringLiteral("benchGlobalGm")).toBool() && + (ptpRole_ != QStringLiteral("SOURCE") || + !identity_.capabilities.contains(QStringLiteral("PTP-GM-BENCH-V1"), + Qt::CaseInsensitive))) { + setError(QStringLiteral("BENCH GM requires SOURCE role and PTP-GM-BENCH-V1 firmware.")); + return false; + } const QString command = ptpConfigCommand(profile); if (command.isEmpty()) { setError(QStringLiteral("PTP expert profile is outside firmware safety bounds.")); From 60165e6db88f8c502aa2a4a5f8a09aae1bf06dae Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:36:51 +0700 Subject: [PATCH 27/27] fix(sv): preserve aggregate source compatibility by appending gmIdentity after sample_payload --- include/ariec61850/sampled_values/asdu.hpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/include/ariec61850/sampled_values/asdu.hpp b/include/ariec61850/sampled_values/asdu.hpp index 2222a55cc..8ec18e013 100644 --- a/include/ariec61850/sampled_values/asdu.hpp +++ b/include/ariec61850/sampled_values/asdu.hpp @@ -23,10 +23,11 @@ struct SampledValueAsdu final { std::uint8_t sample_synchronization{0U}; std::optional sample_rate; std::optional sample_mode; + std::vector sample_payload; // IEC 61850-9-2 Ed.2.1: SynchSrcID/gmIdentity, eight bytes in the // same network order as the PTP GrandmasterIdentity in Announce. + // Append after payload to preserve existing aggregate initializers. std::optional> gm_identity; - std::vector sample_payload; friend bool operator==(const SampledValueAsdu&, const SampledValueAsdu&) = default; };