From a404b7784a9ca42d65fccf7965d193d3606628bd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 11:02:23 +0700 Subject: [PATCH 01/10] feat(ptp): explicit finite evidence continuity for active SOURCE without resurrecting stale or reset clocks --- .../time_sync/ptp_power_profile.hpp | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/include/ariec61850/time_sync/ptp_power_profile.hpp b/include/ariec61850/time_sync/ptp_power_profile.hpp index d2233c137..2e68addf3 100644 --- a/include/ariec61850/time_sync/ptp_power_profile.hpp +++ b/include/ariec61850/time_sync/ptp_power_profile.hpp @@ -67,6 +67,27 @@ struct PtpSourceEvidenceFreshness final { } }; +// The PTP timebase/GM generation is NOT a per-packet transport-success bit. +// An isolated failed Pdelay reply or HW TX timestamp can invalidate cold +// readiness, but it cannot step a still-running EMAC clock or erase the last +// independently successful 1-Hz Announce + Sync/Follow_Up pair. Retain an +// ESTABLISHED lease only while *both* real TX timestamps are still inside +// the strict evidence freshness timeout. Any real SOURCE STOP, clock reset, +// dead link, or expiration must revoke immediately. Cold START still +// requires a complete newly validated pair. +struct PtpSourceEvidenceContinuity final { + [[nodiscard]] static constexpr bool ready( + const bool cold_complete_pair, + const bool previously_ready, + const bool both_positive_timestamps_fresh, + const bool physical_source_active, + const bool stop_or_clock_fault) noexcept { + return physical_source_active && !stop_or_clock_fault && + both_positive_timestamps_fresh && + (cold_complete_pair || previously_ready); + } +}; + // A plausible RTC and a configurable UTC offset do not by themselves prove // the TAI/PTP timescale. Only set the timescale AND offset-valid flags when // the EMAC was actually seeded and the offset was explicitly verified. From 21c9541e0608468dcfbb07957be6c0e2c9f4a079 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 11:02:35 +0700 Subject: [PATCH 02/10] fix(ptp): retain last verified 1Hz PTP TX evidence across isolated burst errors; never discard actual EMAC clock lease per packet --- .../main/ptp_lab_task.cpp | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index 090730827..51776bc27 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -51,6 +51,7 @@ using ar::iec61850::time_sync::PtpPortIdentity; using ar::iec61850::time_sync::PtpPublisherOptions; using ar::iec61850::time_sync::PtpPublisherRuntime; using ar::iec61850::time_sync::PtpSourceReadinessGate; +using ar::iec61850::time_sync::PtpSourceEvidenceContinuity; using ar::iec61850::time_sync::PtpSourceEvidenceFreshness; using ar::iec61850::time_sync::conservative_lab_time_properties; using ar::iec61850::time_sync::inspect_ptp_power_profile_cadence; @@ -949,20 +950,28 @@ void ptp_lab_task(void* argument) { } success = pair_delivered && success; } - // A failed peer-delay response in the same batch must also revoke - // any freshly sent core messages; no readiness resurrection by Pdelay. + // A failed TX batch invalidates *cold* readiness: it must not + // promote a new SOURCE based on one new message. However it MUST + // NOT zero the timestamps of independently successful Announce and + // Sync/Follow_Up packets. That was tearing down the still-running + // source clock lease beside every 1-Hz PTP TX epoch even while all + // three packets appeared on the wire, cycling SV smpSynch 2->0->2. + // Positive evidence ages out naturally within the existing bounded + // ~3s SOURCE freshness interval. Any physical STOP/clock fault still + // invalidates the lease, and this does not assert UTC traceability. if (!success) { source_readiness.invalidate(); - g_ptp_last_announce_us.store(0LL, std::memory_order_release); - g_ptp_last_sync_pair_us.store(0LL, std::memory_order_release); } - const bool qualified_ready = source_readiness.ready() && - PtpSourceEvidenceFreshness::fresh( - esp_timer_get_time(), - g_ptp_last_announce_us.load(std::memory_order_acquire), - g_ptp_last_sync_pair_us.load(std::memory_order_acquire), - g_ptp_freshness_timeout_us.load(std::memory_order_acquire)); const bool was_ready = g_ptp_ready.load(std::memory_order_acquire); + const bool positive_evidence_fresh = PtpSourceEvidenceFreshness::fresh( + esp_timer_get_time(), + g_ptp_last_announce_us.load(std::memory_order_acquire), + g_ptp_last_sync_pair_us.load(std::memory_order_acquire), + g_ptp_freshness_timeout_us.load(std::memory_order_acquire)); + const bool qualified_ready = PtpSourceEvidenceContinuity::ready( + source_readiness.ready(), was_ready, positive_evidence_fresh, + g_ptp_started.load(std::memory_order_acquire), + g_stop_requested.load(std::memory_order_acquire)); if (!qualified_ready) { g_ptp_ready.store(false, std::memory_order_release); } else if (!was_ready) { From 36dc9b0183ae8bc4ed23f4b6adde25a806995fde Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 11:03:03 +0700 Subject: [PATCH 03/10] test(ptp): positive PTP evidence withstands 1Hz transport glitches, expires and fails closed after real stop/clock fault --- tests/test_ptp_power_profile.cpp | 44 ++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/test_ptp_power_profile.cpp b/tests/test_ptp_power_profile.cpp index 4e5fae791..730ad9c7a 100644 --- a/tests/test_ptp_power_profile.cpp +++ b/tests/test_ptp_power_profile.cpp @@ -87,6 +87,49 @@ void check_source_readiness_requires_fresh_wire_proof() { REQUIRE(!gate.ready()); } +void check_ptp_positive_evidence_retains_clock_across_isolated_tx_faults() { + using Continuity = PtpSourceEvidenceContinuity; + using Fresh = PtpSourceEvidenceFreshness; + PtpSourceReadinessGate cold; + cold.observe_announce(true); + REQUIRE(!Continuity::ready(cold.ready(), false, true, true, false)); + cold.observe_sync_pair(true); + REQUIRE(Continuity::ready(cold.ready(), false, true, true, false)); + + // Physical PCAP regression: an internal SOURCE continues sending 1-Hz + // Announce/Sync/Follow_Up, but a failed peripheral TX status briefly + // invalidates the cold gate. The actual GM timebase has NOT changed. + constexpr std::int64_t kLastAnnounceUs = 6000000; + constexpr std::int64_t kLastSyncPairUs = 6000050; + constexpr std::int64_t kFreshnessUs = 3000000; + cold.observe_peer_delay(false); + REQUIRE(!cold.ready()); + for (std::int64_t now = 6000100; now < 8000000; now += 250000) { + const bool recent = Fresh::fresh( + now, kLastAnnounceUs, kLastSyncPairUs, kFreshnessUs); + REQUIRE(recent); + REQUIRE(Continuity::ready(cold.ready(), true, recent, true, false)); + } + // Never give an unqualified COLD board credit for previous TX. + REQUIRE(!Continuity::ready(false, false, true, true, false)); + // The finite freshness budget must eventually expire. + REQUIRE(!Continuity::ready( + false, true, Fresh::fresh(9000100, kLastAnnounceUs, + kLastSyncPairUs, kFreshnessUs), true, false)); + // Real physical termination, GM reset, link loss or STOP fail closed + // instantly, even while the last successful frames remain fresh. + REQUIRE(!Continuity::ready(true, true, true, false, false)); + REQUIRE(!Continuity::ready(true, true, true, true, true)); + REQUIRE(!Continuity::ready(true, true, false, true, false)); + // Requalification after actual source outage requires both fresh packets. + cold.invalidate(); + REQUIRE(!Continuity::ready(cold.ready(), false, true, true, false)); + cold.observe_announce(true); + REQUIRE(!Continuity::ready(cold.ready(), false, true, true, false)); + cold.observe_sync_pair(true); + REQUIRE(Continuity::ready(cold.ready(), false, true, true, false)); +} + void check_source_heartbeat_freshness_and_time_provenance() { // A late task or absent Announce must revoke READY without waiting for // another SOURCE loop. A clock going backward is never healthy. @@ -353,6 +396,7 @@ int main() { try { check_defaults_and_reject_nonstandard_cadence(); check_source_readiness_requires_fresh_wire_proof(); + check_ptp_positive_evidence_retains_clock_across_isolated_tx_faults(); check_source_heartbeat_freshness_and_time_provenance(); check_ptp_wire_sequence_and_identity(); check_simulated_global_gm_ptp_wire_properties(); From fd6247ef44e2e945635b4871ecae029832c33253 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 11:04:05 +0700 Subject: [PATCH 04/10] test(ptp): 100 live 1Hz epochs maintain measured SOURCE lease across isolated TX fault without hardcoding smpSynch --- tests/test_ptp_power_profile.cpp | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_ptp_power_profile.cpp b/tests/test_ptp_power_profile.cpp index 730ad9c7a..0cab14912 100644 --- a/tests/test_ptp_power_profile.cpp +++ b/tests/test_ptp_power_profile.cpp @@ -110,6 +110,30 @@ void check_ptp_positive_evidence_retains_clock_across_isolated_tx_faults() { REQUIRE(recent); REQUIRE(Continuity::ready(cold.ready(), true, recent, true, false)); } + // 100 consecutive 1-Hz SOURCE epochs with a bounded isolated + // timestamp/peer-delay failure on every seventh epoch. The last + // genuinely successful pair, not an invented counter, is carried. + PtpSourceReadinessGate many; + bool previous_ready = false; + std::int64_t last_announce = 0, last_pair = 0; + for (std::int64_t epoch = 1; epoch <= 100; ++epoch) { + const std::int64_t now = 10000000 + epoch * 1000000; + const bool isolated_fault = epoch > 1 && (epoch % 7) == 0; + if (isolated_fault) { + many.observe_peer_delay(false); + } else { + many.observe_announce(true); + many.observe_sync_pair(true); + last_announce = now - 150; + last_pair = now - 30; + } + const bool wire_fresh = Fresh::fresh( + now, last_announce, last_pair, kFreshnessUs); + const bool current_ready = Continuity::ready( + many.ready(), previous_ready, wire_fresh, true, false); + REQUIRE(current_ready); + previous_ready = current_ready; + } // Never give an unqualified COLD board credit for previous TX. REQUIRE(!Continuity::ready(false, false, true, true, false)); // The finite freshness budget must eventually expire. From 604ac5b8d92ee8d60d1fdbd27e7322fc8643cafb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 11:04:16 +0700 Subject: [PATCH 05/10] test(studio): enforce bounded PTP SOURCE evidence continuity from regression PCAP, not per-burst READY flutter --- .../tests/check_operator_workspace.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 2e9d2b66b..d986a44e1 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -830,6 +830,26 @@ def main() -> None: and "startDeviceDiscovery()" in session_cpp, "hotplug must use one worker loss authority and queued generation-safe supervisor reconnect") + # M3.H on-wire 54,979-SV regression: four sync losses occur within + # 22–49us after a 1-Hz PTP Follow_Up although all PTP messages remain + # consecutive. One TX-status fault must not erase last *positive* core + # Announce + complete Sync/Follow_Up timestamps and tear down an + # unchanged EMAC PTP clock. Finite freshness and physical-stop fail + # gates remain mandatory. + ptp_policy = (STUDIO.parents[1] / "include/ariec61850/time_sync/ptp_power_profile.hpp").read_text(encoding="utf-8") + ptp_task = (STUDIO.parents[1] / "embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp").read_text(encoding="utf-8") + failure_segment = ptp_task.split("// A failed TX batch invalidates *cold* readiness:", 1)[1].split( + "const bool was_ready =", 1)[0] + require("PtpSourceEvidenceContinuity" in ptp_policy + and "PtpSourceEvidenceContinuity::ready(" in ptp_task + and "source_readiness.invalidate();" in failure_segment + and "g_ptp_last_announce_us.store(0LL" not in failure_segment + and "g_ptp_last_sync_pair_us.store(0LL" not in failure_segment + and "g_stop_requested.load(std::memory_order_acquire)" in ptp_task + and "check_ptp_positive_evidence_retains_clock_across_isolated_tx_faults();" in + (STUDIO.parents[1] / "tests/test_ptp_power_profile.cpp").read_text(encoding="utf-8"), + "PTP TX failure must never erase fresh positive wire evidence or force a 1Hz SV loss") + print("Studio operator workspace contract: PASS") From 3f4d4c7b3765f4e3cda6d6647891482baa0810d5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 11:05:55 +0700 Subject: [PATCH 06/10] feat(studio): focus reconnect identity scan on credible USB/verified COM, preserve generic bridge fallback --- apps/arstack_studio/src/DeviceIoWorker.hpp | 25 ++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/arstack_studio/src/DeviceIoWorker.hpp b/apps/arstack_studio/src/DeviceIoWorker.hpp index a4e56c4f3..1ad0dba2c 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.hpp +++ b/apps/arstack_studio/src/DeviceIoWorker.hpp @@ -51,6 +51,30 @@ class DeviceIoWorker final : public QObject { prioritize(verifiedPort); return visible; } + // Once USB metadata identifies a physical ESP32/CH343 candidate (or + // a previous port answered semantic IDENTIFY), never spend five retries + // apiece on unrelated Bluetooth/legacy COM ports before returning to + // that device. When Windows exposes NO qualified physical metadata, + // keep the existing generic fallback so first-time/generic bridges work. + // This narrows *candidate selection* only; IDENTIFY remains mandatory. + [[nodiscard]] static QStringList focusedAutomaticPorts( + const QStringList& allVisible, const QStringList& physicalCandidates, + const QString& verifiedPort, const QString& recommended) { + QStringList focused; + for (const QString& port : physicalCandidates) { + if (allVisible.contains(port, Qt::CaseInsensitive) && + !focused.contains(port, Qt::CaseInsensitive)) + focused.push_back(port); + } + const auto previous = verifiedPort.trimmed(); + if (!previous.isEmpty() && + allVisible.contains(previous, Qt::CaseInsensitive) && + !focused.contains(previous, Qt::CaseInsensitive)) + focused.push_back(previous); + if (focused.isEmpty()) focused = allVisible; + return orderedDiscoveryPorts(focused, verifiedPort, recommended); + } + [[nodiscard]] static constexpr int heartbeatIntervalMs() noexcept { return 700; } // Allow the ESP32-P4 line-oriented console to drain a complete command // before accepting the next command, even when the host USB buffer emptied. @@ -198,6 +222,7 @@ public slots: QTimer* writeGapTimer_{nullptr}; QStringList ports_; + QStringList physicallyPlausiblePorts_; QString recommendedPort_; QStringList probeQueue_; QByteArray pendingRx_; From b1d84a6e98f3ee9207bb5a57397c20b58f6ac099 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 11:06:05 +0700 Subject: [PATCH 07/10] fix(studio): stop chasing unrelated Bluetooth/legacy COM ports on verified USB reconnect --- apps/arstack_studio/src/DeviceIoWorker.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/DeviceIoWorker.cpp b/apps/arstack_studio/src/DeviceIoWorker.cpp index c22de17b4..980931306 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.cpp +++ b/apps/arstack_studio/src/DeviceIoWorker.cpp @@ -191,6 +191,7 @@ void DeviceIoWorker::refreshPortsInternal(const bool forceSignal) { highConfidence.size() != highConfidenceCount_; ports_ = std::move(discovered); + physicallyPlausiblePorts_ = std::move(highConfidence); recommendedPort_ = recommended; highConfidenceCount_ = highConfidence.size(); if (forceSignal || changed) @@ -234,8 +235,8 @@ void DeviceIoWorker::autoDetectAndConnect(const quint64 generation) { const QSettings settings; const QString verifiedPort = settings.value( QStringLiteral("device-discovery/lastVerifiedPort")).toString().trimmed(); - probeQueue_ = orderedDiscoveryPorts( - ports_, verifiedPort, recommendedPort_); + probeQueue_ = focusedAutomaticPorts( + ports_, physicallyPlausiblePorts_, verifiedPort, recommendedPort_); genericProbeActive_ = !probeQueue_.isEmpty(); if (genericProbeActive_) tryNextProbe(); else emit automaticProbeExhausted(activeGeneration_); From 6e2d5412d374ad77820a928a37acd4784e6e7636 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 11:06:17 +0700 Subject: [PATCH 08/10] test(studio): COM15 hotplug selects credible USB without multi-COM 5.5s retry cascade --- .../src/DeterministicSessionHarness.cpp | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 3bf3d68d0..5725c1577 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -37,6 +37,7 @@ class DeterministicSessionHarness final { {"firmware build mismatch explains expected and observed IDs", reconnectBuildMismatchPreservesEvidence()}, {"fragmented espflash progress -> monotonic", fragmentedEspflashProgressIsMonotonic()}, {"verified COM hint falls back without bypassing identity", verifiedComOrderingPreservesFallback()}, + {"reconnect focuses verified USB, never blind Bluetooth COM sweeps", fastPhysicalUsbCandidateSelection()}, {"automatic IDENTIFY timeout never implies missing firmware", automaticIdentityTimeoutOffersFirmwareRecovery()}, {"single COM semantic timeout stays unverified", singleVisibleTimeoutWithoutRecommendationOffersFirmwareRecovery()}, {"ambiguous identity timeout -> UNIDENTIFIED", ambiguousIdentityTimeoutStaysUnidentified()}, @@ -458,6 +459,35 @@ class DeterministicSessionHarness final { DeviceIoWorker::identityRetryIntervalMs() >= 1000; } + static bool fastPhysicalUsbCandidateSelection() { + const QStringList allPorts{ + QStringLiteral("COM3"), QStringLiteral("COM9"), + QStringLiteral("COM15"), QStringLiteral("COM24")}; + const QStringList trustedPhysical{QStringLiteral("COM15")}; + const QStringList realOnly{QStringLiteral("COM15")}; + // Restore within one semantic timeout rather than chasing several + // unrelated generic/virtual ports for 5 * 1100ms EACH. + return DeviceIoWorker::focusedAutomaticPorts( + allPorts, trustedPhysical, + QStringLiteral("COM15"), QStringLiteral("COM15")) == realOnly && + DeviceIoWorker::focusedAutomaticPorts( + allPorts, trustedPhysical, + QStringLiteral("COM99"), QStringLiteral("COM15")) == realOnly && + DeviceIoWorker::focusedAutomaticPorts( + allPorts, QStringList{}, + QStringLiteral("COM15"), QString{}) == realOnly && + // Initial setup without USB metadata is still supported. + DeviceIoWorker::focusedAutomaticPorts( + allPorts, QStringList{}, QString{}, QString{}) == allPorts && + // Multiple physically plausible devices require *real* + // IDENTIFY of each, ordered by verified identity. + DeviceIoWorker::focusedAutomaticPorts( + allPorts, + QStringList{QStringLiteral("COM9"), QStringLiteral("COM15")}, + QStringLiteral("COM15"), QStringLiteral("COM9")) == + QStringList{QStringLiteral("COM15"), QStringLiteral("COM9")}; + } + static bool automaticIdentityTimeoutOffersFirmwareRecovery() { Fixture fixture; if (!fixture.profileReady || !seedIdentityTimeout(fixture, false)) return false; From b200b25fabea721f115424d982d780d842d961fa Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 11:06:26 +0700 Subject: [PATCH 09/10] test(studio): prevent slow multi-COM discovery regressions during hotplug recovery --- apps/arstack_studio/tests/check_operator_workspace.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index d986a44e1..04dec0ca0 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -850,6 +850,14 @@ def main() -> None: (STUDIO.parents[1] / "tests/test_ptp_power_profile.cpp").read_text(encoding="utf-8"), "PTP TX failure must never erase fresh positive wire evidence or force a 1Hz SV loss") + require("focusedAutomaticPorts(" in io_header + and "physicallyPlausiblePorts_" in io_header + and "physicallyPlausiblePorts_ = std::move(highConfidence)" in io_worker + and "focusedAutomaticPorts(" in io_worker + and "fastPhysicalUsbCandidateSelection()" in + (STUDIO / "src/DeterministicSessionHarness.cpp").read_text(encoding="utf-8"), + "verified USB reconnect must not wait for unrelated Bluetooth COM retry sweeps") + print("Studio operator workspace contract: PASS") From a922e9bdba4e57a4e88406eb83344455dd8ff142 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 11:09:07 +0700 Subject: [PATCH 10/10] test(studio): enforce narrowed USB discovery and preserved verified COM semantics after smarter candidate ordering --- apps/arstack_studio/tests/check_operator_workspace.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 04dec0ca0..a817101ec 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -98,7 +98,8 @@ def main() -> None: session_cpp = (STUDIO / "src/SmartSessionController.cpp").read_text(encoding="utf-8") ribbon_qml = source("ModernRibbon.qml") require('device-discovery/lastVerifiedPort' in io_worker - and "orderedDiscoveryPorts(" in io_worker + and "focusedAutomaticPorts(" in io_worker + and "orderedDiscoveryPorts(" in io_header and "identityMaxAttempts() noexcept { return 5; }" in io_header and "manualRecoveryArmed_" in session_cpp and "FIRMWARE RECOVERY" in session_cpp