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 diff --git a/apps/arstack_studio/qml/ProfileInspector.qml b/apps/arstack_studio/qml/ProfileInspector.qml index 61889cd37..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 @@ -261,6 +272,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 diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index 61a5885e8..2328ddc78 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"] @@ -42,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 } } @@ -51,6 +53,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) @@ -274,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. @@ -549,6 +576,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 diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 942e1031e..d472a4b7a 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()) { @@ -1353,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 { @@ -1428,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( @@ -1675,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.")); 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 && 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_; diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index d893e1937..1eb412506 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 @@ -34,6 +35,7 @@ #include #include #include +#include #include #ifndef ARSTACK_STUDIO_VERSION @@ -199,8 +201,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; } diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 2999de093..fd5969b30 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_runtime.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}}; diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 16df8d22d..245d9fbe7 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."); } @@ -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" @@ -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; } diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index 34db58c09..a3cfcfe13 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,15 @@ 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)) { + error = "BENCH GM requires a Studio UTC epoch"; + return false; + } if (config.role == AR_PTP_ROLE_LAB_SOURCE && (config.clock_class != 248U || config.clock_accuracy != @@ -382,12 +403,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); @@ -609,6 +654,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 +689,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 +729,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 +749,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 +938,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 +1098,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( 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); 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)); diff --git a/include/ariec61850/sampled_values/asdu.hpp b/include/ariec61850/sampled_values/asdu.hpp index aae458257..8ec18e013 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 @@ -23,6 +24,10 @@ struct SampledValueAsdu final { 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; friend bool operator==(const SampledValueAsdu&, const SampledValueAsdu&) = default; }; 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; } 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(); diff --git a/tests/test_sampled_values.cpp b/tests/test_sampled_values.cpp index e37923575..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() { @@ -638,11 +640,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},