From 3994f786a7361ef9d29e00e212c0d6b2bdb8f835 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 14:35:00 +0700 Subject: [PATCH 1/5] feat(sv): encode SCL-specific Ed2.1 smpRate/smpMod semantics on 4I4V publisher --- .../esp32p4_smv_injector/main/app_main.cpp | 8 +++- .../main/compiled_profile_adapter.hpp | 14 +++++- .../main/runtime_profile.cpp | 13 ++++++ .../main/runtime_profile.hpp | 3 ++ .../esp32p4_profile_support.hpp | 9 +++- tests/test_sampled_values.cpp | 44 +++++++++++++++++++ 6 files changed, 86 insertions(+), 5 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 1d3030938..89c7dbfab 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -308,8 +308,12 @@ SampledValuesFrame make_runtime_frame( asdu.sample_synchronization = 0U; if (diagnostic_mirror || profile.include_sample_rate) { - asdu.sample_rate = static_cast(profile.publisher_rate_hz); - asdu.sample_mode = kSampleModeSamplesPerSecond; + // Wire units follow the selected SCL profile, never FPS by assumption. + asdu.sample_rate = diagnostic_mirror || profile.wire_sample_rate == 0U + ? static_cast(profile.publisher_rate_hz) + : profile.wire_sample_rate; + asdu.sample_mode = diagnostic_mirror + ? kSampleModeSamplesPerSecond : profile.wire_sample_mode; } else { asdu.sample_rate.reset(); asdu.sample_mode.reset(); diff --git a/embedded/esp32p4_smv_injector/main/compiled_profile_adapter.hpp b/embedded/esp32p4_smv_injector/main/compiled_profile_adapter.hpp index 79b78dba6..7b0c71238 100644 --- a/embedded/esp32p4_smv_injector/main/compiled_profile_adapter.hpp +++ b/embedded/esp32p4_smv_injector/main/compiled_profile_adapter.hpp @@ -29,9 +29,16 @@ enum class BinaryRuntimeProfileStatus : std::uint8_t { } if ((decoded.profile_family != SvProfileFamily::iec61850_9_2 && decoded.profile_family != SvProfileFamily::legacy_9_2le) || - decoded.sampling_basis != SvSampleMode::samples_per_second || + (decoded.sampling_basis != SvSampleMode::samples_per_second && + decoded.sampling_basis != SvSampleMode::samples_per_period) || decoded.no_asdu != 1U || - decoded.frame_rate_hz != decoded.configured_sample_rate || + !((decoded.sampling_basis == SvSampleMode::samples_per_second && + decoded.frame_rate_hz == decoded.configured_sample_rate) || + (decoded.sampling_basis == SvSampleMode::samples_per_period && + (static_cast(decoded.configured_sample_rate) * 50U == + decoded.frame_rate_hz || + static_cast(decoded.configured_sample_rate) * 60U == + decoded.frame_rate_hz))) || decoded.frame_rate_hz == 0U || decoded.frame_rate_hz > 65535U || decoded.leaf_count != 16U || decoded.payload_size_bytes != 64U || !decoded.asdu_options.sample_synchronized || @@ -68,6 +75,9 @@ enum class BinaryRuntimeProfileStatus : std::uint8_t { candidate.no_asdu = decoded.no_asdu; candidate.include_data_set = decoded.asdu_options.data_set; candidate.include_sample_rate = decoded.asdu_options.sample_rate; + candidate.wire_sample_rate = static_cast(decoded.configured_sample_rate); + candidate.wire_sample_mode = + decoded.sampling_basis == SvSampleMode::samples_per_period ? 0U : 1U; std::copy_n(decoded.sv_id.begin(), decoded.sv_id_length, candidate.sv_id.begin()); std::copy_n(decoded.data_set_reference.begin(), diff --git a/embedded/esp32p4_smv_injector/main/runtime_profile.cpp b/embedded/esp32p4_smv_injector/main/runtime_profile.cpp index 58dcc194c..719f79242 100644 --- a/embedded/esp32p4_smv_injector/main/runtime_profile.cpp +++ b/embedded/esp32p4_smv_injector/main/runtime_profile.cpp @@ -53,6 +53,19 @@ bool runtime_profile_validate(const RuntimePublisherProfile& profile) noexcept { !ar::iec61850::ethernet::is_valid_vlan_priority(profile.vlan_priority))) { return false; } + if (profile.include_sample_rate) { + const auto declared = profile.wire_sample_rate == 0U + ? profile.publisher_rate_hz + : static_cast(profile.wire_sample_rate); + if (declared == 0U || declared > 65535U || + profile.wire_sample_mode > 1U || + (profile.wire_sample_mode == 1U && declared != profile.publisher_rate_hz) || + (profile.wire_sample_mode == 0U && + profile.publisher_rate_hz != declared * 50U && + profile.publisher_rate_hz != declared * 60U)) { + return false; // Reject unit-inconsistent metadata. + } + } if (profile.include_data_set && profile.data_set_reference[0] == '\0') { return false; } diff --git a/embedded/esp32p4_smv_injector/main/runtime_profile.hpp b/embedded/esp32p4_smv_injector/main/runtime_profile.hpp index e444e84a0..c6e8948a0 100644 --- a/embedded/esp32p4_smv_injector/main/runtime_profile.hpp +++ b/embedded/esp32p4_smv_injector/main/runtime_profile.hpp @@ -20,6 +20,9 @@ struct RuntimePublisherProfile final { std::uint16_t no_asdu{1U}; bool include_data_set{false}; bool include_sample_rate{false}; + // Ed2.1 optional wire values (zero is legacy ASCII auto-rate). + std::uint16_t wire_sample_rate{0U}; + std::uint16_t wire_sample_mode{1U}; std::array sv_id{}; std::array data_set_reference{}; // Zero for factory/default or legacy ASCII commits. A valid value is set diff --git a/include/ariec61850/sampled_values/esp32p4_profile_support.hpp b/include/ariec61850/sampled_values/esp32p4_profile_support.hpp index 7e324cb15..7489b3917 100644 --- a/include/ariec61850/sampled_values/esp32p4_profile_support.hpp +++ b/include/ariec61850/sampled_values/esp32p4_profile_support.hpp @@ -36,7 +36,14 @@ namespace detail { [[nodiscard]] inline bool esp32p4_4i4v_layout_matches( const SvPublisherProfile& profile) { const auto frame_rate_hz = profile.timing.exact_frame_rate_hz(); - if (profile.timing.sampling_basis != SvSampleMode::samples_per_second || + const bool supported_basis = + profile.timing.sampling_basis == SvSampleMode::samples_per_second || + (profile.timing.sampling_basis == SvSampleMode::samples_per_period && + profile.timing.configured_sample_rate != 0U && + frame_rate_hz.has_value() && + (*frame_rate_hz == profile.timing.configured_sample_rate * 50ULL || + *frame_rate_hz == profile.timing.configured_sample_rate * 60ULL)); + if (!supported_basis || profile.timing.asdus_per_frame != 1U || !legacy_9_2le_4i4v_layout_matches(profile) || !frame_rate_hz.has_value() || *frame_rate_hz == 0U || diff --git a/tests/test_sampled_values.cpp b/tests/test_sampled_values.cpp index d384fdf26..970dafac0 100644 --- a/tests/test_sampled_values.cpp +++ b/tests/test_sampled_values.cpp @@ -470,6 +470,50 @@ void compiled_binary_profile_activates_only_supported_4i4v_device_layout() { CHECK(runtime.app_id == 0x4001U); CHECK(runtime.sv_id[0] == 'S' && runtime.sv_id[2] == '2'); CHECK(runtime.include_data_set && runtime.include_sample_rate); + CHECK(runtime.wire_sample_rate == 4800U); + CHECK(runtime.wire_sample_mode == 1U); + for (const auto nominal_hz : {50U, 60U}) { + auto per_period = device; + per_period.sampling_basis = SvSampleMode::samples_per_period; + per_period.configured_sample_rate = 80U; + per_period.frame_rate_hz = 80U * nominal_hz; + per_period.sample_counter_modulus = static_cast(80U * nominal_hz); + const auto bytes_needed = SvDeviceProfileBinaryCodec::encoded_size(per_period); + CHECK(bytes_needed.has_value()); + std::vector rate_bytes(*bytes_needed); + CHECK(SvDeviceProfileBinaryCodec::encode_into(per_period, rate_bytes).success()); + RuntimePublisherProfile period_runtime{}; + CHECK(decode_binary_runtime_profile(rate_bytes, period_runtime) == + BinaryRuntimeProfileStatus::ok); + CHECK(period_runtime.wire_sample_rate == 80U); + CHECK(period_runtime.wire_sample_mode == 0U); + CHECK(period_runtime.publisher_rate_hz == 80U * nominal_hz); + SampledValueAsdu asdu; + asdu.sv_id = "ARSTACK_SV01"; + asdu.sample_rate = period_runtime.wire_sample_rate; + asdu.sample_mode = period_runtime.wire_sample_mode; + asdu.sample_payload.resize(64U); + const auto wire = SampledValuesPduCodec::encode(SampledValuesPdu{{asdu}}); + SampledValuesPdu decoded_wire{}; + CHECK(SampledValuesPduCodec::try_decode(wire, decoded_wire)); + CHECK(decoded_wire.asdus[0].sample_rate == 80U); + CHECK(decoded_wire.asdus[0].sample_mode == 0U); + per_period.frame_rate_hz += 10U; + CHECK(SvDeviceProfileBinaryCodec::encode_into(per_period, rate_bytes).success()); + CHECK(decode_binary_runtime_profile(rate_bytes, period_runtime) == + BinaryRuntimeProfileStatus::unsupported_device_capability); + } + auto legacy = device; + legacy.profile_family = SvProfileFamily::legacy_9_2le; + legacy.asdu_options.sample_rate = false; + const auto legacy_len = SvDeviceProfileBinaryCodec::encoded_size(legacy); + CHECK(legacy_len.has_value()); + std::vector legacy_bytes(*legacy_len); + CHECK(SvDeviceProfileBinaryCodec::encode_into(legacy, legacy_bytes).success()); + RuntimePublisherProfile legacy_runtime{}; + CHECK(decode_binary_runtime_profile(legacy_bytes, legacy_runtime) == + BinaryRuntimeProfileStatus::ok); + CHECK(!legacy_runtime.include_sample_rate); CHECK(runtime.data_set_reference[0] == 'D'); const auto previous_generation = runtime.generation; chunks.abort(); From 17a4fec776d31070dd7cd2ad9fafa09ab58bdc6f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 14:36:07 +0700 Subject: [PATCH 2/5] feat(studio): preview exact SCL wire metadata without modifying legacy ASCII --- apps/arstack_studio/qml/ProfileInspector.qml | 9 +++++++++ apps/arstack_studio/src/SclProfileModel.cpp | 9 +++++++++ embedded/esp32p4_smv_injector/main/profile_control.cpp | 3 +++ 3 files changed, 21 insertions(+) diff --git a/apps/arstack_studio/qml/ProfileInspector.qml b/apps/arstack_studio/qml/ProfileInspector.qml index dc6f9edd7..b5b423df6 100644 --- a/apps/arstack_studio/qml/ProfileInspector.qml +++ b/apps/arstack_studio/qml/ProfileInspector.qml @@ -256,6 +256,15 @@ SurfacePanel { font.family: panel.monoFont font.pixelSize: panel.theme.captionSize } + Label { text: "SV wire"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize } + Label { + text: panel.profile.includeSampleRate + ? "smpRate " + panel.profile.wireSmpRate + " · smpMod " + panel.profile.wireSmpMod + : "smpRate / smpMod omitted (optional)" + color: panel.theme.textSoft + font.family: panel.monoFont + font.pixelSize: panel.theme.captionSize + } Label { text: "Frames"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize } Label { text: panel.profile.frameRateHz diff --git a/apps/arstack_studio/src/SclProfileModel.cpp b/apps/arstack_studio/src/SclProfileModel.cpp index 39dfd365f..6bf78ada6 100644 --- a/apps/arstack_studio/src/SclProfileModel.cpp +++ b/apps/arstack_studio/src/SclProfileModel.cpp @@ -518,6 +518,15 @@ QVariantMap SclProfileModel::profileToVariantMap(const SvPublisherProfile& p) co map.insert(QStringLiteral("channelLeafCount"), static_cast(p.channels.size())); map.insert(QStringLiteral("includeDataSet"), p.asdu_options.data_set); map.insert(QStringLiteral("includeSampleRate"), p.asdu_options.sample_rate); + map.insert(QStringLiteral("wireSmpRate"), + p.asdu_options.sample_rate + ? QVariant::fromValue(static_cast(p.timing.configured_sample_rate)) + : QVariant{}); + map.insert(QStringLiteral("wireSmpMod"), + p.asdu_options.sample_rate + ? QVariant::fromValue(p.timing.sampling_basis == SvSampleMode::samples_per_period + ? 0 : p.timing.sampling_basis == SvSampleMode::samples_per_second ? 1 : -1) + : QVariant{}); map.insert(QStringLiteral("sampleSynchronizedField"), p.asdu_options.sample_synchronized); map.insert(QStringLiteral("iec61869Present"), p.iec61869_9.has_value()); diff --git a/embedded/esp32p4_smv_injector/main/profile_control.cpp b/embedded/esp32p4_smv_injector/main/profile_control.cpp index f46d18a27..ec18dd539 100644 --- a/embedded/esp32p4_smv_injector/main/profile_control.cpp +++ b/embedded/esp32p4_smv_injector/main/profile_control.cpp @@ -524,6 +524,9 @@ void handle_profile_command(char* arguments) noexcept { g_staging.no_asdu = static_cast(no_asdu); g_staging.include_data_set = (flags & 0x1U) != 0U; g_staging.include_sample_rate = (flags & 0x2U) != 0U; + // ASCII PROFILE SV remains samples/second, never inherits SCL mode. + g_staging.wire_sample_rate = 0U; + g_staging.wire_sample_mode = 1U; return; } From 3ca1ff9d06d3006f49501818d5c7b81299affbe8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 14:45:44 +0700 Subject: [PATCH 3/5] test(studio): accept measured SOURCE_FAST telemetry while retaining conservative receiver epoch gates --- apps/arstack_studio/tests/check_operator_workspace.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index e54123a1a..0ad7e54db 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -62,7 +62,9 @@ def main() -> None: and "local_acquisition.observe_preflight(" in firmware_source and "local_acquisition.observe_live(" in firmware_source and "local_acquisition.reset();" in firmware_source - and "live_clean=%lu/%lu warm_phase=%lu/%lu live_age=%lu" in firmware_source + and "live_clean=%lu/%lu source_fast=%lu/%lu warm_phase=%lu/%lu " in firmware_source + and "SvSourceSelfClockAcquisition" in sv_gate + and "source_fast" in firmware_source and "kRequiredCleanEpochs = 10U" in sv_gate and "kWarmRequiredLiveEpochs = 3U" in sv_gate and "kMaxWarmMacAcquisitionEpochs = 8U" in sv_gate @@ -70,8 +72,8 @@ def main() -> None: and "check_no_intermitttent_local_at_start()" in sv_tests and "check_warm_phase_preflight_cannot_fake_canonical_mac()" in sv_tests and "check_warm_preflight_negative_session_and_clock_faults()" in sv_tests, - "LOCAL REF: ten LIVE epochs for COLD, ten stable phase-only PREFLIGHT epochs " - "plus three verified LIVE MAC epochs for WARM; invalidate on loss of evidence") + "RECEIVER conservative warm/cold gates and SOURCE_FAST measured two-epoch " + "phase/MAC gate must coexist; revoke on evidence loss") phase_header = (project_root / "include/ariec61850/time_sync/sv_ptp_phase_authority.hpp").read_text(encoding="utf-8") phase_tests = (project_root / "tests/test_sv_ptp_phase_authority.cpp").read_text(encoding="utf-8") require("g_preflight_phase_clock_active" in firmware_source From fa80cef953d874c704c064faf3878b432e0a1000 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 14:47:56 +0700 Subject: [PATCH 4/5] ci(studio): publish direct SHA-pinned PTP/SV bench Inno EXE only after full smoke and install FAT --- .../arstack-studio-native-preview.yml | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index 83d1a5013..8bc213d89 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -31,6 +31,8 @@ jobs: windows-preview: needs: firmware if: ${{ !github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository }} + permissions: + contents: write # Direct immutable engineering prerelease, never stable runs-on: windows-2022 timeout-minutes: 40 env: @@ -380,6 +382,75 @@ jobs: Remove-Item $note -Force Write-Host 'Actual v0.1.1 NSIS to Inno + owned cleanup + data preservation: PASS' + - name: Publish verified direct Smart Inno EXE (exact SHA engineering prerelease) + if: github.event.pull_request.head.ref == 'feat/sv-ed21-rate-metadata-20261022' + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference = 'Stop' + $sha = $env:PREVIEW_SHA.ToLowerInvariant() + if ($sha -cnotmatch '^[0-9a-f]{40} + uses: actions/upload-artifact@v4 + with: + name: arstack-studio-native-preview-win-x64-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }} + path: | + dist/ARStack-Studio-Preview-win-x64-setup.exe + dist/ARStack-Studio-Preview-win-x64-portable.zip + dist/SHA256SUMS.txt + if-no-files-found: error + retention-days: 7 +) { throw 'Invalid immutable preview source SHA' } + $checked = (git rev-parse HEAD).Trim().ToLowerInvariant() + if ($checked -cne $sha) { throw "GUI/firmware head drift $checked != $sha" } + $short = $sha.Substring(0,12) + $tag = "ptp-sv-bench-$short" + $installer = Join-Path $PWD 'dist\ARStack-Studio-Preview-win-x64-setup.exe' + $manifestFile = Join-Path $PWD 'firmware-package\firmware-manifest.json' + if (-not (Test-Path -LiteralPath $installer -PathType Leaf) -or + -not (Test-Path -LiteralPath $manifestFile -PathType Leaf)) { + throw 'Missing tested Inno executable or matched firmware manifest' + } + $manifest = Get-Content -Raw -LiteralPath $manifestFile | ConvertFrom-Json + $imageFile = Join-Path $PWD ("firmware-package\" + $manifest.image) + if ($manifest.sourceCommit -cne $sha -or + -not (Test-Path -LiteralPath $imageFile -PathType Leaf) -or + (Get-FileHash -LiteralPath $imageFile -Algorithm SHA256).Hash.ToLowerInvariant() -cne + $manifest.sha256.ToLowerInvariant()) { + throw 'Exact firmware source or binary digest mismatch before publication' + } + $filename = "ARStack-Studio-0.1.1-ptp-sv-bench.$short-win-x64-setup.exe" + $direct = Join-Path $PWD ("dist\" + $filename) + Copy-Item -LiteralPath $installer -Destination $direct -Force + $sha256 = (Get-FileHash -LiteralPath $direct -Algorithm SHA256).Hash.ToLowerInvariant() + $sums = Join-Path $PWD 'dist\SHA256-INNO.txt' + "$sha256 $filename" | Set-Content -LiteralPath $sums -Encoding ascii + $notes = Join-Path $PWD 'dist\ptp-sv-preview-notes.md' + @( + 'ARStack ESP32-P4 PTP/SV engineering bench prerelease — NOT stable / NOT relay-FAT qualified.' + "Exact GUI and firmware source SHA: $sha" + "Firmware binary SHA256: $($manifest.sha256)" + "Windows Inno setup SHA256: $sha256" + 'Built and smoke-tested together in one Windows + ESP32 CI workflow.' + 'Physical SIPROTEC GM LOCKED, SMV ACCEPTED and 1-hour soak are not asserted.' + 'Never install on a production substation from this bench preview.' + ) | Set-Content -LiteralPath $notes -Encoding utf8 + gh release view $tag --repo $env:GITHUB_REPOSITORY *> $null + if ($LASTEXITCODE -eq 0) { throw "Immutable bench tag already exists: $tag" } + gh release create $tag $direct $sums --repo $env:GITHUB_REPOSITORY --target $sha --title "ARStack PTP/SV bench $short" --notes-file $notes --prerelease --latest=false + if ($LASTEXITCODE -ne 0) { throw 'Direct Inno prerelease creation failed' } + $published = gh release view $tag --repo $env:GITHUB_REPOSITORY --json tagName,isPrerelease,assets | ConvertFrom-Json + if ($LASTEXITCODE -ne 0 -or -not $published.isPrerelease -or + $published.tagName -cne $tag -or + -not @($published.assets | Where-Object { $_.name -ceq $filename }).Count) { + throw 'Published direct .exe cannot be verified' + } + $url = "https://github.com/$env:GITHUB_REPOSITORY/releases/download/$tag/$filename" + "Verified direct Smart Inno EXE: $url" | Out-File $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + "Setup SHA256: $sha256" | Out-File $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + Write-Host "DIRECT_INNO_EXE=$url" + Write-Host "DIRECT_INNO_SHA256=$sha256" + - name: Upload GUI-only Windows preview packages uses: actions/upload-artifact@v4 with: From 88575b874f35827afa54c86f294e11a798f6b8ae Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 14:49:53 +0700 Subject: [PATCH 5/5] feat(ptp-ui): separate local GM TX, measured SV phase and unverified relay lock --- apps/arstack_studio/qml/PtpExpertPanel.qml | 46 ++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index 2328ddc78..0003b85e4 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -86,6 +86,17 @@ SurfacePanel { : panel.device.smpSynchMeasured ? "MEASURED SYNC FLAG · verify sample-phase evidence independently" : "FREE-RUN / UNQUALIFIED · PTP packets do not prove SV sample lock" + readonly property string gmTxEvidence: panel.sourceRole && panel.device.ptpRunning + ? (panel.sourceEvidence ? "TX OBSERVED" : "TX ACQUIRING") + : "NOT TRANSMITTING" + readonly property string svPhaseEvidence: panel.device.smpSynchSimulated + ? "MANUAL FLAG ONLY" + : panel.device.smpSynchMeasured + ? "MEASURED · smpSynch " + panel.device.smpSynchValue + : "NOT QUALIFIED" + // Firmware cannot observe another device's PTP servo or SV acceptance. + // Do not manufacture a relay LOCKED state from local Announce counters. + readonly property string relayLockEvidence: "NOT OBSERVED · CHECK RELAY" readonly property bool receiverRole: activeRole === "RECEIVER" readonly property bool sourceEvidence: Number(panel.device.ptpAnnounceSent) > 0 && Number(panel.device.ptpSyncSent) > 0 @@ -286,6 +297,41 @@ SurfacePanel { } } + Rectangle { + Layout.fillWidth: true + implicitHeight: 52 + radius: 7 + color: panel.theme.panelAlt + border.width: 1 + border.color: panel.theme.lineSoft + + RowLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 10 + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + Label { text: "PTP GM TX"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + Label { text: panel.gmTxEvidence; color: panel.sourceEvidence && panel.sourceRole ? panel.theme.green : panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 10 } + } + Rectangle { width: 1; Layout.fillHeight: true; color: panel.theme.lineSoft } + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + Label { text: "SV phase / MAC"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + Label { text: panel.svPhaseEvidence; color: panel.device.smpSynchMeasured && !panel.device.smpSynchSimulated ? panel.theme.green : panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 10 } + } + Rectangle { width: 1; Layout.fillHeight: true; color: panel.theme.lineSoft } + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + Label { text: "Relay follower"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + Label { text: panel.relayLockEvidence; color: panel.theme.muted; font.family: panel.monoFont; font.pixelSize: 10 } + } + } + } + StudioDarkCheckBox { id: benchGmCheck Layout.fillWidth: true