Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 22 additions & 14 deletions apps/arstack_studio/qml/StudioDarkComboBox.qml
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,37 @@ import QtQuick.Controls
ComboBox {
id: combo
property var theme
// Qt may instantiate this control before its parent assigns theme.
// Use compact immutable default tokens until the real StudioTheme binds.
// Avoid QML TypeError / palette flicker during startup, without a worker.
readonly property var effectiveTheme: theme || ({
text: "#edf2f7", muted: "#91a1b3", textSoft: "#c2ccd7",
surface2: "#0e141b", raised: "#151d27",
raisedHover: "#1a2430", accent: "#69a9ff", line: "#26313d"
})
property string uiFont: "Inter"

implicitHeight: 36
hoverEnabled: true
font.family: uiFont
font.pixelSize: 11
palette.text: theme.text
palette.buttonText: theme.text
palette.windowText: theme.text
palette.base: theme.surface2
palette.button: theme.raised
palette.text: effectiveTheme.text
palette.buttonText: effectiveTheme.text
palette.windowText: effectiveTheme.text
palette.base: effectiveTheme.surface2
palette.button: effectiveTheme.raised

background: Rectangle {
radius: 7
color: combo.enabled ? (combo.hovered ? combo.theme.raisedHover : combo.theme.surface2)
: combo.theme.surface2
color: combo.enabled ? (combo.hovered ? combo.effectiveTheme.raisedHover : combo.effectiveTheme.surface2)
: combo.effectiveTheme.surface2
border.width: 1
border.color: combo.activeFocus ? combo.theme.accent : combo.theme.line
border.color: combo.activeFocus ? combo.effectiveTheme.accent : combo.effectiveTheme.line
}

contentItem: Text {
text: combo.displayText
color: combo.enabled ? combo.theme.text : combo.theme.muted
color: combo.enabled ? combo.effectiveTheme.text : combo.effectiveTheme.muted
font: combo.font
verticalAlignment: Text.AlignVCenter
leftPadding: 12
Expand All @@ -39,7 +47,7 @@ ComboBox {

indicator: Text {
text: "\u25be"
color: combo.enabled ? combo.theme.textSoft : combo.theme.muted
color: combo.enabled ? combo.effectiveTheme.textSoft : combo.effectiveTheme.muted
font.pixelSize: 14
anchors.right: parent.right
anchors.rightMargin: 11
Expand All @@ -55,7 +63,7 @@ ComboBox {
hoverEnabled: true
contentItem: Text {
text: combo.textAt(choice.index)
color: choice.highlighted || choice.hovered ? "#142334" : combo.theme.text
color: choice.highlighted || choice.hovered ? "#142334" : combo.effectiveTheme.text
font: combo.font
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
Expand All @@ -64,7 +72,7 @@ ComboBox {
}
background: Rectangle {
radius: 5
color: choice.highlighted || choice.hovered ? "#edf5ff" : combo.theme.raised
color: choice.highlighted || choice.hovered ? "#edf5ff" : combo.effectiveTheme.raised
}
}

Expand All @@ -81,10 +89,10 @@ ComboBox {
ScrollIndicator.vertical: ScrollIndicator {}
}
background: Rectangle {
color: combo.theme.raised
color: combo.effectiveTheme.raised
radius: 8
border.width: 1
border.color: combo.theme.line
border.color: combo.effectiveTheme.line
}
}
}
22 changes: 22 additions & 0 deletions apps/arstack_studio/tests/check_operator_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,28 @@ def main() -> None:
and "panel.session.requestPtpAdvancedPolicy(mode)" in expert,
"M3.D Advanced PTP policy must be firmware-enforced, board-readback and session-owned")

# M3.D regression: SOURCE policy selection must preserve a still-fresh
# bench GM seed. The portable policy test checks positive/negative cases;
# this source contract verifies firmware actually uses that policy.
ptp_source_task = (project_root / "embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp").read_text(encoding="utf-8")
require("may_preserve_bench_seed(" in policy_header
and "PtpAdvancedPolicy::may_preserve_bench_seed(" in ptp_facade
and "if (!keep_seed)" in ptp_facade
and "PtpAdvancedPolicy::kBenchSeedMaxAgeUs" in ptp_source_task,
"M3.D must retain only a fresh SOURCE bench GM seed across SOURCE policy selection")

# Boot and fallback must use the SAME explicit isolated simulated GM
# policy. A bare LOCAL SOURCE default would regress smpSynch to 1.
firmware_defaults = (project_root / "embedded/esp32p4_smv_injector/sdkconfig.defaults").read_text(encoding="utf-8")
phase_header = (project_root / "include/ariec61850/time_sync/sv_ptp_phase_authority.hpp").read_text(encoding="utf-8")
require("CONFIG_AR_PTP_BOOT_SIMULATED_GLOBAL_GM=y" in firmware_defaults
and "CONFIG_AR_PTP_BOOT_SIMULATED_GLOBAL_GM" in ptp_source_task
and "fresh_lab.bench_global_gm" in firmware_source
and "SvWarmResumePolicy::retain" in firmware_source
and "SvWarmResumePolicy" in phase_header
and "g_warm_resume_first_epoch" in firmware_source,
"isolated simulated GM must boot/fallback and warm SV resume must requalify MAC")

print("Studio operator workspace contract: PASS")


Expand Down
14 changes: 14 additions & 0 deletions embedded/esp32p4_smv_injector/main/Kconfig.projbuild
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,20 @@ config AR_PTP_BOOT_SOURCE_AUTOSTART
SV Ethernet transmission remains OFF until operator SV START.
Never enable while connected to an energized utility process bus.

config AR_PTP_BOOT_SIMULATED_GLOBAL_GM
bool "ISOLATED FAT ONLY: boot as simulated Global PTP Grandmaster"
default n
depends on AR_PTP_BOOT_SOURCE_AUTOSTART
help
Explicit IEC 61850 laboratory TEST-SET simulation. On isolated
Ethernet, the ESP32-P4 PTP SOURCE emulates GNSS-quality Announce
metadata and a PTP/TAI epoch without a real GPS. Its own EMAC clock
drives both PTP and the PTP-aligned SV grid. A plausible RTC epoch is
used if available; otherwise a fixed synthetic UTC anchor is used
with monotonic uptime (NOT true UTC or certified 1-us accuracy).
smpSynch=2 is STILL gated on measured SV sample phase and canonical
MAC evidence. DO NOT enable on a live utility process bus.

config AR_PTP_AUTO_PREFER_EXTERNAL
bool "LAB ONLY: prefer better qualified external GM using safe EMAC handover"
default n
Expand Down
106 changes: 102 additions & 4 deletions embedded/esp32p4_smv_injector/main/app_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,13 @@ constexpr std::uint32_t kEgressEvidenceEveryZeroEpochs =
portMUX_TYPE g_tx_egress_mux = portMUX_INITIALIZER_UNLOCKED;
SvTxEgressEvidence g_tx_egress_evidence{};
SvLocalSyncEvidenceGate g_local_sync_evidence{}; // same critical section as egress tracker
// SOURCE-only certified resume: the dry GPTimer stays aligned across STOP.
// The first new MAC baseline is NOT a clock step; any real error revokes.
// Zero means cold/expired. Never read these atomics from the 4-kHz ISR.
std::atomic<bool> g_warm_resume_baseline_consumed{false};
#endif
#if AR_SV_PTP_PHASE_SUPPORTED
std::atomic<std::uint32_t> g_warm_resume_first_epoch{0U};
SemaphoreHandle_t g_phase_timer_mutex = nullptr;
gptimer_handle_t g_monitored_sample_clock = nullptr; // under mutex, never in ISR
TaskHandle_t g_phase_observer_task = nullptr;
Expand Down Expand Up @@ -762,6 +767,9 @@ void stop_sample_clock(gptimer_handle_t& timer) noexcept {
// Release the wire claim before teardown; old MAC evidence never
// survives STOP/START, even when the same SOURCE later restarts.
revoke_sv_auto_claims(SvSyncLossReason::publisher_stop);
#if AR_SV_TX_EGRESS_SUPPORTED
g_warm_resume_first_epoch.store(0U, std::memory_order_release);
#endif
if (timer == nullptr) return;
#if AR_SV_PTP_PHASE_SUPPORTED
// Serialize teardown with the non-ISR phase observer, so a GPTimer
Expand Down Expand Up @@ -973,13 +981,29 @@ void sv_boot_clock_supervisor_task(void*) {
"M3C no selected external GM for >=15s: explicit lab fallback");
ar_ptp_lab_stop(); // fully drain receiver ownership first
ar_ptp_lab_config_t local = config;
// A stale Studio BENCH_GM epoch must NOT be reused on
// fallback. Local oscillator profile makes no GNSS claim.
// Failover changes the clock lease and must reacquire phase.
// Never recycle a stale Studio seed. When the isolated FAT
// build explicitly enables boot simulated GM, obtain a NEW
// monotonic/RTC-derived test-set seed instead of silently
// regressing from GLOBAL to LOCAL during local fallback.
local.role = AR_PTP_ROLE_LAB_SOURCE;
local.bench_global_gm = false;
local.bench_utc_epoch_seconds = 0U;
local.bench_utc_epoch_millisecond = 0U;
local.bench_epoch_received_mono_us = 0LL;
#if defined(CONFIG_AR_PTP_BOOT_SIMULATED_GLOBAL_GM) && CONFIG_AR_PTP_BOOT_SIMULATED_GLOBAL_GM
ar_ptp_lab_config_t fresh_lab{};
ar_ptp_lab_get_default_config(&fresh_lab);
if (fresh_lab.role == AR_PTP_ROLE_LAB_SOURCE &&
fresh_lab.bench_global_gm) {
local.bench_global_gm = true;
local.bench_utc_epoch_seconds = fresh_lab.bench_utc_epoch_seconds;
local.bench_utc_epoch_millisecond =
fresh_lab.bench_utc_epoch_millisecond;
local.bench_epoch_received_mono_us =
fresh_lab.bench_epoch_received_mono_us;
}
#endif
external_runtime_selected = false;
// Prevent indefinite repeated local promotions if startup
// fails; only explicit PTP control may reset this latch.
Expand All @@ -992,7 +1016,8 @@ void sv_boot_clock_supervisor_task(void*) {
next_external_handover_ms = now_ms +
ar::iec61850::time_sync::PtpExternalHandoverGuard::kMinSwitchDwellMs;
ESP_LOGI(kTag,
"M3C restored lab LOCAL SOURCE; new generation requires fresh SV phase");
"M3C restored lab SOURCE; simulated Global GM=%u; new phase required",
local.bench_global_gm ? 1U : 0U);
} else {
ESP_LOGE(kTag,
"M3C lab SOURCE restoration FAILED; leave PTP unqualified");
Expand Down Expand Up @@ -1122,6 +1147,21 @@ void publisher_task(void* argument) {
preflight_rate_hz == requested_profile.publisher_rate_hz,
auto_mode);
if (!reuse_preflight) stop_sample_clock(sample_clock);
// Only previously measured SOURCE synchronization on the exact
// same dry-running sample grid may survive START. The 1-Hz
// phase observer and per-frame link/fault/lease checks remain
// authoritative. Fresh MAC descriptors must follow within 8 s.
const bool warm_resume = ar::iec61850::time_sync::SvWarmResumePolicy::may_begin(
source_unchanged, reuse_preflight,
smp_synch_source_local_qualified(),
g_sample_clock_context.phase_authority_valid.load(
std::memory_order_acquire) &&
ar::iec61850::time_sync::SvPhaseObservationFreshness::fresh(
g_sample_clock_context.last_phase_observed_epoch.load(
std::memory_order_acquire),
g_sample_clock_context.epoch_generation.load(
std::memory_order_acquire)),
now_lease.role == PtpAuthorityRole::source);
#else
constexpr bool reuse_preflight = false;
stop_sample_clock(sample_clock);
Expand Down Expand Up @@ -1161,6 +1201,15 @@ void publisher_task(void* argument) {
g_local_sync_evidence.reset();
g_tx_egress_evidence.reset();
portEXIT_CRITICAL(&g_tx_egress_mux);
#if AR_SV_PTP_PHASE_SUPPORTED
g_warm_resume_first_epoch.store(
warm_resume
? g_sample_clock_context.epoch_generation.load(
std::memory_order_acquire) : 0U,
std::memory_order_release);
Comment on lines +1205 to +1209

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the reacquisition deadline across repeated warm starts

If an already-qualified SOURCE repeatedly STOPs and STARTs before obtaining a new coherent MAC pair, every START resets both evidence trackers and replaces the warm-resume deadline with the current epoch. The retained qualification makes the next START eligible for warm resume again, so cycles shorter than the two-epoch descriptor cadence can advertise synchronization indefinitely without fresh MAC evidence, bypassing the stated eight-epoch reacquisition bound. Preserve the original outstanding deadline across these restarts, or require successful MAC requalification before granting another warm window.

Useful? React with 👍 / 👎.

g_warm_resume_baseline_consumed.store(
false, std::memory_order_release);
#endif
#endif
#if AR_SV_PTP_PHASE_SUPPORTED
previous_slew_total = 0;
Expand Down Expand Up @@ -1368,6 +1417,17 @@ void publisher_task(void* argument) {
// continue advertising previously measured local synchronization.
if (smp_synch_source_local_qualified() ||
smp_synch_receiver_timeline_qualified()) {
#if AR_SV_TX_EGRESS_SUPPORTED
const auto warm_first = g_warm_resume_first_epoch.load(
std::memory_order_acquire);
if (warm_first != 0U &&
!ar::iec61850::time_sync::SvWarmResumePolicy::within_window(
warm_first, g_sample_clock_context.epoch_generation.load(
std::memory_order_acquire))) {
g_warm_resume_first_epoch.store(0U, std::memory_order_release);
revoke_sv_auto_claims(SvSyncLossReason::mac_baseline_reset);
}
#endif
const bool phase_ok = g_sample_clock_context.phase_authority_valid.load(
std::memory_order_acquire);
const bool observer_fresh =
Expand Down Expand Up @@ -1485,6 +1545,27 @@ void publisher_task(void* argument) {
// once every two seconds, never per-frame or in an ISR.
using Evidence = ar::iec61850::time_sync::SvTxEvidenceResult;
if (result != Evidence::measured) {
// On a genuinely warm SOURCE restart, the FIRST new MAC
// baseline is expected. It is not lost sample phase.
// One exemption only; repeated baselines or any real
// descriptor/lease/TX fault revoke immediately.
const auto warm_first = g_warm_resume_first_epoch.load(
std::memory_order_acquire);
const bool same_warm_lease =
evidence_lease.role == PtpAuthorityRole::source &&
same_sv_ptp_clock_lease(
evidence_lease, read_ptp_epoch_clock_lease());
bool expected_first_baseline = false;
if (result == Evidence::baseline_established &&
same_warm_lease &&
ar::iec61850::time_sync::SvWarmResumePolicy::within_window(
warm_first, egress.sample_epoch_generation)) {
bool unused = false;
expected_first_baseline =
g_warm_resume_baseline_consumed.compare_exchange_strong(
unused, true, std::memory_order_acq_rel);
}
if (!expected_first_baseline) {
const auto cause = result == Evidence::transmit_failed
? SvSyncLossReason::canonical_tx
: result == Evidence::missing_hw_timestamp
Expand All @@ -1499,6 +1580,8 @@ void publisher_task(void* argument) {
? SvSyncLossReason::mac_baseline_reset
: SvSyncLossReason::mac_egress;
revoke_sv_auto_claims(cause);
g_warm_resume_first_epoch.store(0U, std::memory_order_release);
}
}
}
#else
Expand Down Expand Up @@ -1816,8 +1899,23 @@ void sv_ptp_phase_observer_task(void* argument) {
observation.last_sv_epoch_generation);
if (lease.role != PtpAuthorityRole::source)
source_fast_acquisition.reset();
const std::uint32_t warm_first =
g_warm_resume_first_epoch.load(std::memory_order_acquire);
const bool warm_resume_authorized =
lease.role == PtpAuthorityRole::source &&
ar::iec61850::time_sync::SvWarmResumePolicy::retain(
warm_first, observation.last_sv_epoch_generation,
valid_live_phase,
same_sv_ptp_clock_lease(
armed_lease, g_sample_clock_context.armed_ptp_clock),
no_new_publisher_fault, smp_synch_source_local_qualified());
Comment on lines +1906 to +1911

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Revoke warm retention when fresh MAC evidence fails its bounds

During a warm SOURCE restart, a coherent descriptor pair can return Evidence::measured while its TX offset is outside SvLocalSyncEvidenceGate's allowed range. observe() then resets the MAC qualification, but the publisher only revokes for results other than measured, and this retention path checks software phase and publisher faults without checking that MAC rejection. Consequently, warm_resume_authorized keeps advertising synchronization until the eight-epoch window expires despite newly measured invalid MAC evidence. Distinguish evidence that is still pending from evidence that has failed, and revoke warm retention immediately for the latter.

Useful? React with 👍 / 👎.

if ((stable_source_fast || stable_conservative) && warm_first != 0U)
g_warm_resume_first_epoch.store(0U, std::memory_order_release);
else if (warm_first != 0U && !warm_resume_authorized)
g_warm_resume_first_epoch.store(0U, std::memory_order_release);
const bool stable_local =
stable_conservative || stable_source_fast;
stable_conservative || stable_source_fast ||
warm_resume_authorized;
if (!stable_local &&
(smp_synch_source_local_qualified() ||
smp_synch_receiver_timeline_qualified())) {
Expand Down
28 changes: 27 additions & 1 deletion embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include "ariec61850/time_sync/ptp_runtime.hpp"
#include "ariec61850/time_sync/ptp_tai_source.hpp"
#include "ariec61850/time_sync/ptp_external_candidate_policy.hpp"
#include "ariec61850/time_sync/ptp_advanced_policy.hpp"

#include "esp_err.h"
#include "esp_eth.h"
Expand Down Expand Up @@ -221,6 +222,30 @@ void fill_kconfig_defaults(ar_ptp_lab_config_t& config) noexcept {
config.bench_utc_epoch_seconds = 0U;
config.bench_utc_epoch_millisecond = 0U;
config.bench_epoch_received_mono_us = 0LL;
#if defined(CONFIG_AR_PTP_BOOT_SIMULATED_GLOBAL_GM) && CONFIG_AR_PTP_BOOT_SIMULATED_GLOBAL_GM
// Explicit isolated FAT test-set emulation, never a physical GNSS claim.
// Boot has no Studio/USB UTC seed. Keep PTP and SV on the same EMAC clock.
// If ESP-IDF RTC has valid UTC use it; otherwise monotonic uptime advances
// a fixed synthetic anchor. A receiver may lock to this *simulated* GM,
// but its timestamps are not independently UTC-traceable.
if (config.role == AR_PTP_ROLE_LAB_SOURCE) {
constexpr std::int64_t kSyntheticUtcAnchor = 1767225600LL; // 2026-01-01
const std::int64_t monotonic_us = esp_timer_get_time();
const std::int64_t rtc_seconds = static_cast<std::int64_t>(std::time(nullptr));
const bool plausible_rtc =
rtc_seconds >= 1577836800LL && rtc_seconds <= UINT32_MAX;
const std::int64_t positive_mono_us =
std::max<std::int64_t>(1LL, monotonic_us);
const std::int64_t epoch = plausible_rtc
? rtc_seconds : kSyntheticUtcAnchor + positive_mono_us / 1000000LL;
config.bench_global_gm = true;
config.bench_utc_epoch_seconds = static_cast<std::uint32_t>(epoch);
config.bench_utc_epoch_millisecond = plausible_rtc
? 0U : static_cast<std::uint16_t>(
(positive_mono_us % 1000000LL) / 1000LL);
config.bench_epoch_received_mono_us = positive_mono_us;
}
#endif

const std::string configured_identity{CONFIG_AR_PTP_CLOCK_IDENTITY};
if (!configured_identity.empty()) {
Expand Down Expand Up @@ -434,7 +459,8 @@ void disable_hardware_ptp(const esp_eth_handle_t eth_handle) noexcept {
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)) {
now_mono_us - config.bench_epoch_received_mono_us >
ar::iec61850::time_sync::PtpAdvancedPolicy::kBenchSeedMaxAgeUs)) {
ESP_LOGE(kTag, "BENCH GM epoch missing or expired before EMAC clock start");
return false;
}
Expand Down
Loading
Loading