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
30 changes: 30 additions & 0 deletions apps/arstack_studio/src/DeterministicSessionHarness.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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()},
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions apps/arstack_studio/src/DeviceIoWorker.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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_);
Expand Down
25 changes: 25 additions & 0 deletions apps/arstack_studio/src/DeviceIoWorker.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -198,6 +222,7 @@ public slots:
QTimer* writeGapTimer_{nullptr};

QStringList ports_;
QStringList physicallyPlausiblePorts_;
QString recommendedPort_;
QStringList probeQueue_;
QByteArray pendingRx_;
Expand Down
31 changes: 30 additions & 1 deletion apps/arstack_studio/tests/check_operator_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -830,6 +831,34 @@ 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")

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")


Expand Down
29 changes: 19 additions & 10 deletions embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
21 changes: 21 additions & 0 deletions include/ariec61850/time_sync/ptp_power_profile.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
68 changes: 68 additions & 0 deletions tests/test_ptp_power_profile.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,73 @@ 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));
}
// 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.
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.
Expand Down Expand Up @@ -353,6 +420,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();
Expand Down
Loading