From 49c36bc543ec7ca4d6c8e663a51ee36280478277 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:03:20 +0700 Subject: [PATCH 01/39] feat(ptp): add portable PTPv2 core model --- include/ariec61850/time_sync/ptp.hpp | 183 +++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 include/ariec61850/time_sync/ptp.hpp diff --git a/include/ariec61850/time_sync/ptp.hpp b/include/ariec61850/time_sync/ptp.hpp new file mode 100644 index 000000000..f9796d9b6 --- /dev/null +++ b/include/ariec61850/time_sync/ptp.hpp @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "ariec61850/ethernet/ethernet.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::time_sync { + +inline constexpr std::uint16_t ptp_ethertype = 0x88F7U; +inline constexpr std::uint16_t ptp_vlan_ethertype = 0x8100U; +inline constexpr std::uint16_t ptp_qinq_ethertype = 0x88A8U; +inline constexpr std::uint8_t ptp_version = 2U; +inline constexpr std::size_t ptp_header_length = 34U; +inline constexpr std::array ptp_general_multicast_mac{ + 0x01U, 0x1BU, 0x19U, 0x00U, 0x00U, 0x00U}; +inline constexpr std::array ptp_peer_delay_multicast_mac{ + 0x01U, 0x80U, 0xC2U, 0x00U, 0x00U, 0x0EU}; + +enum class PtpMessageType : std::uint8_t { + sync = 0x0U, + delay_req = 0x1U, + pdelay_req = 0x2U, + pdelay_resp = 0x3U, + follow_up = 0x8U, + delay_resp = 0x9U, + pdelay_resp_follow_up = 0xAU, + announce = 0xBU, + signaling = 0xCU, + management = 0xDU, +}; + +enum class PtpClockAccuracy : std::uint8_t { + within_25_ns = 0x20U, + within_100_ns = 0x21U, + within_250_ns = 0x22U, + within_1_us = 0x23U, + within_2_5_us = 0x24U, + within_10_us = 0x25U, + within_25_us = 0x26U, + within_100_us = 0x27U, + within_250_us = 0x28U, + within_1_ms = 0x29U, + within_2_5_ms = 0x2AU, + within_10_ms = 0x2BU, + greater_than_10_s = 0x31U, + unknown = 0xFEU, +}; + +enum class PtpTimeSource : std::uint8_t { + atomic_clock = 0x10U, + gps = 0x20U, + terrestrial_radio = 0x30U, + ptp = 0x40U, + ntp = 0x50U, + hand_set = 0x60U, + other = 0x90U, + internal_oscillator = 0xA0U, +}; + +using PtpClockIdentity = std::array; + +struct PtpPortIdentity final { + PtpClockIdentity clock_identity{}; + std::uint16_t port_number{1U}; + + friend bool operator==(const PtpPortIdentity&, const PtpPortIdentity&) = default; +}; + +struct PtpTimestamp final { + std::uint64_t seconds{}; // PTP wire field is 48-bit; upper bits are rejected when encoded. + std::uint32_t nanoseconds{}; + + [[nodiscard]] static PtpTimestamp from_system_time( + std::chrono::system_clock::time_point time) noexcept; + [[nodiscard]] static PtpTimestamp now_utc() noexcept; + [[nodiscard]] static bool try_read(std::span source, PtpTimestamp& timestamp) noexcept; + [[nodiscard]] bool write(std::span destination) const noexcept; + + friend bool operator==(const PtpTimestamp&, const PtpTimestamp&) = default; +}; + +struct PtpHeader final { + std::uint8_t transport_specific{}; + PtpMessageType message_type{PtpMessageType::sync}; + std::uint8_t version{ptp_version}; + std::uint16_t message_length{}; + std::uint8_t domain_number{}; + std::uint16_t flags{}; + std::int64_t correction_field{}; + PtpPortIdentity source_port_identity{}; + std::uint16_t sequence_id{}; + std::uint8_t control_field{}; + std::int8_t log_message_interval{}; + + [[nodiscard]] bool is_two_step() const noexcept { return (flags & 0x0200U) != 0U; } + + friend bool operator==(const PtpHeader&, const PtpHeader&) = default; +}; + +struct PtpAnnounceMessage final { + PtpTimestamp origin_timestamp{}; + std::int16_t current_utc_offset{37}; + std::uint8_t priority1{128U}; + std::uint8_t clock_class{248U}; + PtpClockAccuracy clock_accuracy{PtpClockAccuracy::unknown}; + std::uint16_t offset_scaled_log_variance{0xFFFFU}; + std::uint8_t priority2{128U}; + PtpClockIdentity grandmaster_identity{}; + std::uint16_t steps_removed{}; + PtpTimeSource time_source{PtpTimeSource::internal_oscillator}; + + friend bool operator==(const PtpAnnounceMessage&, const PtpAnnounceMessage&) = default; +}; + +struct PtpFrame final { + PtpHeader header{}; + std::optional timestamp; + std::optional announce; + std::vector message; + std::vector body; + std::optional vlan_id; + std::optional outer_vlan_id; + bool peer_delay_multicast{}; +}; + +struct PtpBuildOptions final { + std::uint8_t transport_specific{}; + std::uint8_t domain_number{}; + PtpPortIdentity source_port_identity{}; + std::uint16_t sequence_id{}; + std::int64_t correction_field{}; + std::int8_t log_message_interval{}; + bool two_step{true}; + std::uint8_t priority1{128U}; + std::uint8_t priority2{128U}; + std::uint8_t clock_class{248U}; + PtpClockAccuracy clock_accuracy{PtpClockAccuracy::unknown}; + std::uint16_t offset_scaled_log_variance{0xFFFFU}; + PtpClockIdentity grandmaster_identity{}; + std::uint16_t steps_removed{}; + PtpTimeSource time_source{PtpTimeSource::internal_oscillator}; + PtpTimestamp timestamp{}; +}; + +class PtpCodec final { +public: + [[nodiscard]] static std::vector build_sync(const PtpBuildOptions& options); + [[nodiscard]] static std::vector build_follow_up(const PtpBuildOptions& options); + [[nodiscard]] static std::vector build_pdelay_req(const PtpBuildOptions& options); + [[nodiscard]] static std::vector build_pdelay_resp( + const PtpBuildOptions& options, + const PtpPortIdentity& requesting_port_identity); + [[nodiscard]] static std::vector build_pdelay_resp_follow_up( + const PtpBuildOptions& options, + const PtpPortIdentity& requesting_port_identity); + [[nodiscard]] static std::vector build_announce( + const PtpBuildOptions& options, + std::int16_t current_utc_offset = 37); + + [[nodiscard]] static std::vector build_ethernet_frame( + const std::array& destination_mac, + const std::array& source_mac, + std::span ptp_message, + std::optional vlan_id = std::nullopt, + std::uint8_t vlan_priority = 0U); + + [[nodiscard]] static bool try_parse_message( + std::span message, + PtpFrame& frame) noexcept; + [[nodiscard]] static bool try_parse_ethernet_frame( + std::span ethernet_frame, + PtpFrame& frame) noexcept; +}; + +} // namespace ar::iec61850::time_sync From 4f547b7eea6b5b482fa612c3cc54900010065411 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:04:19 +0700 Subject: [PATCH 02/39] feat(ptp): port PTPv2 codec and raw parser --- src/time_sync/ptp.cpp | 355 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 355 insertions(+) create mode 100644 src/time_sync/ptp.cpp diff --git a/src/time_sync/ptp.cpp b/src/time_sync/ptp.cpp new file mode 100644 index 000000000..4ce2c9637 --- /dev/null +++ b/src/time_sync/ptp.cpp @@ -0,0 +1,355 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/time_sync/ptp.hpp" + +#include +#include +#include + +namespace ar::iec61850::time_sync { +namespace { + +[[nodiscard]] std::uint16_t read_u16_be(const std::span source) noexcept { + return static_cast( + (static_cast(source[0]) << 8U) | + static_cast(source[1])); +} + +[[nodiscard]] std::uint32_t read_u32_be(const std::span source) noexcept { + return (static_cast(source[0]) << 24U) | + (static_cast(source[1]) << 16U) | + (static_cast(source[2]) << 8U) | + static_cast(source[3]); +} + +[[nodiscard]] std::uint64_t read_u64_be(const std::span source) noexcept { + std::uint64_t value{}; + for (std::size_t index = 0U; index < 8U; ++index) { + value = (value << 8U) | static_cast(source[index]); + } + return value; +} + +void write_u16_be(const std::span destination, const std::uint16_t value) noexcept { + destination[0] = static_cast((value >> 8U) & 0xFFU); + destination[1] = static_cast(value & 0xFFU); +} + +void write_u32_be(const std::span destination, const std::uint32_t value) noexcept { + destination[0] = static_cast((value >> 24U) & 0xFFU); + destination[1] = static_cast((value >> 16U) & 0xFFU); + destination[2] = static_cast((value >> 8U) & 0xFFU); + destination[3] = static_cast(value & 0xFFU); +} + +void write_u64_be(const std::span destination, const std::uint64_t value) noexcept { + for (std::size_t index = 0U; index < 8U; ++index) { + const auto shift = static_cast((7U - index) * 8U); + destination[index] = static_cast((value >> shift) & 0xFFU); + } +} + +[[nodiscard]] bool is_zero_identity(const PtpClockIdentity& identity) noexcept { + return std::all_of(identity.begin(), identity.end(), [](const std::uint8_t byte) { + return byte == 0U; + }); +} + +[[nodiscard]] std::vector build_message( + const PtpMessageType message_type, + const PtpBuildOptions& options, + const std::uint8_t control_field, + const std::span body) { + if (body.size() > std::numeric_limits::max() - ptp_header_length) { + return {}; + } + + const auto message_length = ptp_header_length + body.size(); + std::vector bytes(message_length, 0U); + auto span = std::span{bytes}; + span[0] = static_cast( + static_cast(options.transport_specific << 4U) | + (static_cast(message_type) & 0x0FU)); + span[1] = ptp_version; + write_u16_be(span.subspan(2U, 2U), static_cast(message_length)); + span[4] = options.domain_number; + write_u16_be(span.subspan(6U, 2U), options.two_step ? 0x0200U : 0U); + write_u64_be(span.subspan(8U, 8U), static_cast(options.correction_field)); + std::copy( + options.source_port_identity.clock_identity.begin(), + options.source_port_identity.clock_identity.end(), + span.begin() + 20); + write_u16_be(span.subspan(28U, 2U), options.source_port_identity.port_number); + write_u16_be(span.subspan(30U, 2U), options.sequence_id); + span[32] = control_field; + span[33] = static_cast(options.log_message_interval); + std::copy(body.begin(), body.end(), span.begin() + static_cast(ptp_header_length)); + return bytes; +} + +[[nodiscard]] std::vector build_timestamp_message( + const PtpMessageType message_type, + const PtpBuildOptions& options, + const std::uint8_t control_field) { + std::array body{}; + if (!options.timestamp.write(body)) { + return {}; + } + return build_message(message_type, options, control_field, body); +} + +[[nodiscard]] std::uint16_t vlan_id_from_tci(const std::uint16_t tci) noexcept { + return static_cast(tci & 0x0FFFU); +} + +} // namespace + +PtpTimestamp PtpTimestamp::from_system_time(const std::chrono::system_clock::time_point time) noexcept { + using namespace std::chrono; + const auto elapsed = duration_cast(time.time_since_epoch()).count(); + if (elapsed <= 0) { + return {}; + } + constexpr std::int64_t nanos_per_second = 1'000'000'000LL; + const auto seconds_value = elapsed / nanos_per_second; + const auto nanos_value = elapsed % nanos_per_second; + return { + static_cast(seconds_value), + static_cast(nanos_value), + }; +} + +PtpTimestamp PtpTimestamp::now_utc() noexcept { + return from_system_time(std::chrono::system_clock::now()); +} + +bool PtpTimestamp::try_read( + const std::span source, + PtpTimestamp& timestamp) noexcept { + if (source.size() < 10U) { + return false; + } + const auto seconds_high = static_cast(read_u16_be(source.first(2U))); + const auto seconds_low = static_cast(read_u32_be(source.subspan(2U, 4U))); + const auto nanoseconds_value = read_u32_be(source.subspan(6U, 4U)); + if (nanoseconds_value >= 1'000'000'000U) { + return false; + } + timestamp.seconds = (seconds_high << 32U) | seconds_low; + timestamp.nanoseconds = nanoseconds_value; + return true; +} + +bool PtpTimestamp::write(const std::span destination) const noexcept { + constexpr std::uint64_t max_seconds = 0x0000FFFFFFFFFFFFULL; + if (destination.size() < 10U || seconds > max_seconds || nanoseconds >= 1'000'000'000U) { + return false; + } + write_u16_be(destination.first(2U), static_cast(seconds >> 32U)); + write_u32_be(destination.subspan(2U, 4U), static_cast(seconds & 0xFFFFFFFFULL)); + write_u32_be(destination.subspan(6U, 4U), nanoseconds); + return true; +} + +std::vector PtpCodec::build_sync(const PtpBuildOptions& options) { + return build_timestamp_message(PtpMessageType::sync, options, 0x00U); +} + +std::vector PtpCodec::build_follow_up(const PtpBuildOptions& options) { + return build_timestamp_message(PtpMessageType::follow_up, options, 0x02U); +} + +std::vector PtpCodec::build_pdelay_req(const PtpBuildOptions& options) { + return build_timestamp_message(PtpMessageType::pdelay_req, options, 0x05U); +} + +std::vector PtpCodec::build_pdelay_resp( + const PtpBuildOptions& options, + const PtpPortIdentity& requesting_port_identity) { + std::array body{}; + if (!options.timestamp.write(std::span{body}.first(10U))) { + return {}; + } + std::copy( + requesting_port_identity.clock_identity.begin(), + requesting_port_identity.clock_identity.end(), + body.begin() + 10); + write_u16_be(std::span{body}.subspan(18U, 2U), requesting_port_identity.port_number); + return build_message(PtpMessageType::pdelay_resp, options, 0x05U, body); +} + +std::vector PtpCodec::build_pdelay_resp_follow_up( + const PtpBuildOptions& options, + const PtpPortIdentity& requesting_port_identity) { + std::array body{}; + if (!options.timestamp.write(std::span{body}.first(10U))) { + return {}; + } + std::copy( + requesting_port_identity.clock_identity.begin(), + requesting_port_identity.clock_identity.end(), + body.begin() + 10); + write_u16_be(std::span{body}.subspan(18U, 2U), requesting_port_identity.port_number); + return build_message(PtpMessageType::pdelay_resp_follow_up, options, 0x05U, body); +} + +std::vector PtpCodec::build_announce( + const PtpBuildOptions& options, + const std::int16_t current_utc_offset) { + std::array body{}; + if (!options.timestamp.write(std::span{body}.first(10U))) { + return {}; + } + write_u16_be( + std::span{body}.subspan(10U, 2U), + static_cast(current_utc_offset)); + body[13] = options.priority1; + body[14] = options.clock_class; + body[15] = static_cast(options.clock_accuracy); + write_u16_be( + std::span{body}.subspan(16U, 2U), + options.offset_scaled_log_variance); + body[18] = options.priority2; + const auto& grandmaster = is_zero_identity(options.grandmaster_identity) + ? options.source_port_identity.clock_identity + : options.grandmaster_identity; + std::copy(grandmaster.begin(), grandmaster.end(), body.begin() + 19); + write_u16_be(std::span{body}.subspan(27U, 2U), options.steps_removed); + body[29] = static_cast(options.time_source); + return build_message(PtpMessageType::announce, options, 0x05U, body); +} + +std::vector PtpCodec::build_ethernet_frame( + const std::array& destination_mac, + const std::array& source_mac, + const std::span ptp_message, + const std::optional vlan_id, + const std::uint8_t vlan_priority) { + if (vlan_priority > 7U || (vlan_id.has_value() && *vlan_id > 4095U)) { + return {}; + } + + ethernet::EthernetFrame frame; + frame.destination = ethernet::MacAddress{destination_mac}; + frame.source = ethernet::MacAddress{source_mac}; + frame.ether_type = ptp_ethertype; + if (vlan_id.has_value()) { + frame.vlan = ethernet::VlanTag{vlan_priority, *vlan_id}; + } + frame.payload.assign(ptp_message.begin(), ptp_message.end()); + return ethernet::EthernetFrameCodec::encode(frame); +} + +bool PtpCodec::try_parse_message( + const std::span message, + PtpFrame& frame) noexcept { + if (message.size() < ptp_header_length) { + return false; + } + + const auto version = static_cast(message[1] & 0x0FU); + if (version != ptp_version) { + return false; + } + const auto message_length = read_u16_be(message.subspan(2U, 2U)); + if (message_length < ptp_header_length || message.size() < message_length) { + return false; + } + + PtpFrame parsed; + parsed.header.transport_specific = static_cast(message[0] >> 4U); + parsed.header.message_type = static_cast(message[0] & 0x0FU); + parsed.header.version = version; + parsed.header.message_length = message_length; + parsed.header.domain_number = message[4]; + parsed.header.flags = read_u16_be(message.subspan(6U, 2U)); + parsed.header.correction_field = static_cast(read_u64_be(message.subspan(8U, 8U))); + std::copy_n(message.begin() + 20, 8, parsed.header.source_port_identity.clock_identity.begin()); + parsed.header.source_port_identity.port_number = read_u16_be(message.subspan(28U, 2U)); + parsed.header.sequence_id = read_u16_be(message.subspan(30U, 2U)); + parsed.header.control_field = message[32]; + parsed.header.log_message_interval = static_cast(message[33]); + + const auto body = message.subspan(ptp_header_length, message_length - ptp_header_length); + PtpTimestamp timestamp; + if ((parsed.header.message_type == PtpMessageType::sync || + parsed.header.message_type == PtpMessageType::follow_up) && + body.size() >= 10U && PtpTimestamp::try_read(body.first(10U), timestamp)) { + parsed.timestamp = timestamp; + } + + if (parsed.header.message_type == PtpMessageType::announce && body.size() >= 30U) { + if (!PtpTimestamp::try_read(body.first(10U), timestamp)) { + return false; + } + PtpAnnounceMessage announce; + announce.origin_timestamp = timestamp; + announce.current_utc_offset = static_cast(read_u16_be(body.subspan(10U, 2U))); + announce.priority1 = body[13]; + announce.clock_class = body[14]; + announce.clock_accuracy = static_cast(body[15]); + announce.offset_scaled_log_variance = read_u16_be(body.subspan(16U, 2U)); + announce.priority2 = body[18]; + std::copy_n(body.begin() + 19, 8, announce.grandmaster_identity.begin()); + announce.steps_removed = read_u16_be(body.subspan(27U, 2U)); + announce.time_source = static_cast(body[29]); + parsed.timestamp = timestamp; + parsed.announce = announce; + } + + parsed.message.assign(message.begin(), message.begin() + static_cast(message_length)); + parsed.body.assign(body.begin(), body.end()); + frame = std::move(parsed); + return true; +} + +bool PtpCodec::try_parse_ethernet_frame( + const std::span ethernet_frame, + PtpFrame& frame) noexcept { + if (ethernet_frame.size() < 14U) { + return false; + } + + std::size_t offset = 12U; + std::optional outer_vlan; + std::optional vlan; + auto ether_type = read_u16_be(ethernet_frame.subspan(offset, 2U)); + offset += 2U; + + if (ether_type == ptp_qinq_ethertype) { + if (ethernet_frame.size() < offset + 4U) { + return false; + } + outer_vlan = vlan_id_from_tci(read_u16_be(ethernet_frame.subspan(offset, 2U))); + ether_type = read_u16_be(ethernet_frame.subspan(offset + 2U, 2U)); + offset += 4U; + } + + if (ether_type == ptp_vlan_ethertype) { + if (ethernet_frame.size() < offset + 4U) { + return false; + } + vlan = vlan_id_from_tci(read_u16_be(ethernet_frame.subspan(offset, 2U))); + ether_type = read_u16_be(ethernet_frame.subspan(offset + 2U, 2U)); + offset += 4U; + } + + if (ether_type != ptp_ethertype || ethernet_frame.size() < offset + ptp_header_length) { + return false; + } + + PtpFrame parsed; + if (!try_parse_message(ethernet_frame.subspan(offset), parsed)) { + return false; + } + parsed.outer_vlan_id = outer_vlan; + parsed.vlan_id = vlan; + parsed.peer_delay_multicast = std::equal( + ptp_peer_delay_multicast_mac.begin(), + ptp_peer_delay_multicast_mac.end(), + ethernet_frame.begin()); + frame = std::move(parsed); + return true; +} + +} // namespace ar::iec61850::time_sync From 72e78fa10969c7251ba3f34a03c0b5111253b64b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:04:45 +0700 Subject: [PATCH 03/39] feat(ptp): add passive monitor and health model --- include/ariec61850/time_sync/ptp_monitor.hpp | 138 +++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 include/ariec61850/time_sync/ptp_monitor.hpp diff --git a/include/ariec61850/time_sync/ptp_monitor.hpp b/include/ariec61850/time_sync/ptp_monitor.hpp new file mode 100644 index 000000000..368c5b2ac --- /dev/null +++ b/include/ariec61850/time_sync/ptp_monitor.hpp @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "ariec61850/time_sync/ptp.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::time_sync { + +struct PtpObservedMessage final { + std::chrono::system_clock::time_point observed_at{}; + PtpMessageType message_type{PtpMessageType::sync}; + std::uint8_t domain_number{}; + PtpPortIdentity source_port_identity{}; + std::uint16_t sequence_id{}; + bool two_step{}; + std::optional vlan_id; + std::optional outer_vlan_id; + bool peer_delay_multicast{}; +}; + +struct PtpSourceClockSnapshot final { + std::uint8_t domain_number{}; + PtpPortIdentity source_port_identity{}; + std::chrono::system_clock::time_point first_seen_at{}; + std::chrono::system_clock::time_point last_seen_at{}; + std::array message_counts{}; + std::array, 16> last_sequence_ids{}; + std::uint32_t sequence_anomaly_count{}; + std::optional vlan_id; + std::optional outer_vlan_id; + + [[nodiscard]] std::uint32_t count(PtpMessageType type) const noexcept; +}; + +struct PtpMonitorSnapshot final { + std::chrono::system_clock::time_point captured_at{}; + std::uint64_t total_frames{}; + std::uint64_t valid_ptp_frames{}; + std::uint64_t invalid_frames{}; + std::vector recent_messages; + std::vector sources; + + [[nodiscard]] bool has_ptp() const noexcept { return valid_ptp_frames != 0U; } +}; + +class PtpPassiveMonitor final { +public: + explicit PtpPassiveMonitor(std::size_t recent_capacity = 256U); + + [[nodiscard]] bool observe_ethernet_frame( + std::span ethernet_frame, + std::chrono::system_clock::time_point observed_at = std::chrono::system_clock::now()); + void observe( + const PtpFrame& frame, + std::chrono::system_clock::time_point observed_at = std::chrono::system_clock::now()); + [[nodiscard]] PtpMonitorSnapshot snapshot( + std::chrono::system_clock::time_point captured_at = std::chrono::system_clock::now()) const; + void reset(); + +private: + struct SourceState final { + PtpSourceClockSnapshot snapshot; + }; + + [[nodiscard]] static std::size_t message_index(PtpMessageType type) noexcept; + [[nodiscard]] SourceState* find_source( + std::uint8_t domain_number, + const PtpPortIdentity& source_port_identity) noexcept; + + std::size_t recent_capacity_; + mutable std::mutex mutex_; + std::uint64_t total_frames_{}; + std::uint64_t valid_frames_{}; + std::uint64_t invalid_frames_{}; + std::deque recent_messages_; + std::vector sources_; +}; + +enum class PtpHealthSeverity : std::uint8_t { + ok, + warning, + fail, +}; + +struct PtpHealthCheck final { + std::string id; + PtpHealthSeverity severity{PtpHealthSeverity::ok}; + std::string message; +}; + +struct PtpTimingHealthOptions final { + std::optional expected_domain_number; + std::chrono::milliseconds source_timeout{3000}; + bool require_announce{true}; + bool require_sync{true}; + bool require_follow_up_for_two_step{true}; + bool require_peer_delay_activity{false}; + std::uint32_t maximum_sequence_anomalies{}; +}; + +struct PtpTimingHealthReport final { + std::chrono::system_clock::time_point evaluated_at{}; + PtpHealthSeverity severity{PtpHealthSeverity::fail}; + PtpMonitorSnapshot snapshot; + std::vector checks; + std::optional selected_source; + + [[nodiscard]] bool is_healthy() const noexcept { return severity == PtpHealthSeverity::ok; } +}; + +class PtpTimingHealthValidator final { +public: + [[nodiscard]] static PtpTimingHealthReport evaluate( + const PtpMonitorSnapshot& snapshot, + const PtpTimingHealthOptions& options = {}); +}; + +enum class SmpSynchValue : std::uint8_t { + not_synchronized = 0U, + local_synchronized = 1U, + global_synchronized = 2U, +}; + +[[nodiscard]] SmpSynchValue resolve_smp_synch( + const PtpTimingHealthReport& report, + bool allow_local_fallback = true) noexcept; + +} // namespace ar::iec61850::time_sync From 96610f49fe781fe2ed2463c667bc68e064e36b02 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:05:32 +0700 Subject: [PATCH 04/39] feat(ptp): port passive monitor health and smpSynch policy --- src/time_sync/ptp_monitor.cpp | 328 ++++++++++++++++++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 src/time_sync/ptp_monitor.cpp diff --git a/src/time_sync/ptp_monitor.cpp b/src/time_sync/ptp_monitor.cpp new file mode 100644 index 000000000..8fb93cfcd --- /dev/null +++ b/src/time_sync/ptp_monitor.cpp @@ -0,0 +1,328 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/time_sync/ptp_monitor.hpp" + +#include +#include + +namespace ar::iec61850::time_sync { +namespace { + +[[nodiscard]] std::string port_identity_text(const PtpPortIdentity& identity) { + constexpr char digits[] = "0123456789ABCDEF"; + std::string text; + text.reserve(8U * 3U + 5U); + for (std::size_t index = 0U; index < identity.clock_identity.size(); ++index) { + if (index != 0U) text.push_back(':'); + const auto byte = identity.clock_identity[index]; + text.push_back(digits[(byte >> 4U) & 0x0FU]); + text.push_back(digits[byte & 0x0FU]); + } + text.push_back('/'); + text += std::to_string(identity.port_number); + return text; +} + +void add_presence_check( + std::vector& checks, + const PtpSourceClockSnapshot& source, + const PtpMessageType type, + std::string id, + std::string label) { + const auto message_count = source.count(type); + if (message_count != 0U) { + checks.push_back({ + std::move(id), + PtpHealthSeverity::ok, + std::move(label) + " messages are visible from " + port_identity_text(source.source_port_identity) + ".", + }); + } else { + checks.push_back({ + std::move(id), + PtpHealthSeverity::fail, + "No " + std::move(label) + " message is visible from selected PTP source " + + port_identity_text(source.source_port_identity) + ".", + }); + } +} + +[[nodiscard]] PtpHealthSeverity report_severity(const std::vector& checks) noexcept { + if (std::any_of(checks.begin(), checks.end(), [](const PtpHealthCheck& check) { + return check.severity == PtpHealthSeverity::fail; + })) { + return PtpHealthSeverity::fail; + } + if (std::any_of(checks.begin(), checks.end(), [](const PtpHealthCheck& check) { + return check.severity == PtpHealthSeverity::warning; + })) { + return PtpHealthSeverity::warning; + } + return PtpHealthSeverity::ok; +} + +} // namespace + +std::uint32_t PtpSourceClockSnapshot::count(const PtpMessageType type) const noexcept { + const auto index = static_cast(static_cast(type) & 0x0FU); + return message_counts[index]; +} + +PtpPassiveMonitor::PtpPassiveMonitor(const std::size_t recent_capacity) + : recent_capacity_(std::max(recent_capacity, 8U)) {} + +std::size_t PtpPassiveMonitor::message_index(const PtpMessageType type) noexcept { + return static_cast(static_cast(type) & 0x0FU); +} + +PtpPassiveMonitor::SourceState* PtpPassiveMonitor::find_source( + const std::uint8_t domain_number, + const PtpPortIdentity& source_port_identity) noexcept { + const auto iterator = std::find_if(sources_.begin(), sources_.end(), [&](const SourceState& state) { + return state.snapshot.domain_number == domain_number && + state.snapshot.source_port_identity == source_port_identity; + }); + return iterator == sources_.end() ? nullptr : &*iterator; +} + +bool PtpPassiveMonitor::observe_ethernet_frame( + const std::span ethernet_frame, + const std::chrono::system_clock::time_point observed_at) { + { + const std::scoped_lock lock{mutex_}; + ++total_frames_; + } + + PtpFrame frame; + if (!PtpCodec::try_parse_ethernet_frame(ethernet_frame, frame)) { + const std::scoped_lock lock{mutex_}; + ++invalid_frames_; + return false; + } + observe(frame, observed_at); + return true; +} + +void PtpPassiveMonitor::observe( + const PtpFrame& frame, + const std::chrono::system_clock::time_point observed_at) { + const PtpObservedMessage observed{ + observed_at, + frame.header.message_type, + frame.header.domain_number, + frame.header.source_port_identity, + frame.header.sequence_id, + frame.header.is_two_step(), + frame.vlan_id, + frame.outer_vlan_id, + frame.peer_delay_multicast, + }; + + const std::scoped_lock lock{mutex_}; + ++valid_frames_; + recent_messages_.push_back(observed); + while (recent_messages_.size() > recent_capacity_) { + recent_messages_.pop_front(); + } + + auto* source = find_source(frame.header.domain_number, frame.header.source_port_identity); + if (source == nullptr) { + SourceState state; + state.snapshot.domain_number = frame.header.domain_number; + state.snapshot.source_port_identity = frame.header.source_port_identity; + state.snapshot.first_seen_at = observed_at; + state.snapshot.last_seen_at = observed_at; + sources_.push_back(state); + source = &sources_.back(); + } + + auto& state = source->snapshot; + state.last_seen_at = observed_at; + state.vlan_id = frame.vlan_id; + state.outer_vlan_id = frame.outer_vlan_id; + const auto index = message_index(frame.header.message_type); + ++state.message_counts[index]; + if (state.last_sequence_ids[index].has_value()) { + const auto expected = static_cast( + static_cast(*state.last_sequence_ids[index]) + 1U); + if (frame.header.sequence_id != expected) { + ++state.sequence_anomaly_count; + } + } + state.last_sequence_ids[index] = frame.header.sequence_id; +} + +PtpMonitorSnapshot PtpPassiveMonitor::snapshot( + const std::chrono::system_clock::time_point captured_at) const { + const std::scoped_lock lock{mutex_}; + PtpMonitorSnapshot result; + result.captured_at = captured_at; + result.total_frames = total_frames_; + result.valid_ptp_frames = valid_frames_; + result.invalid_frames = invalid_frames_; + result.recent_messages.assign(recent_messages_.begin(), recent_messages_.end()); + result.sources.reserve(sources_.size()); + for (const auto& source : sources_) { + result.sources.push_back(source.snapshot); + } + std::sort(result.sources.begin(), result.sources.end(), [](const auto& left, const auto& right) { + if (left.domain_number != right.domain_number) { + return left.domain_number < right.domain_number; + } + return left.source_port_identity.clock_identity < right.source_port_identity.clock_identity; + }); + return result; +} + +void PtpPassiveMonitor::reset() { + const std::scoped_lock lock{mutex_}; + total_frames_ = 0U; + valid_frames_ = 0U; + invalid_frames_ = 0U; + recent_messages_.clear(); + sources_.clear(); +} + +PtpTimingHealthReport PtpTimingHealthValidator::evaluate( + const PtpMonitorSnapshot& snapshot, + const PtpTimingHealthOptions& options) { + PtpTimingHealthReport report; + report.evaluated_at = snapshot.captured_at; + report.snapshot = snapshot; + + auto& checks = report.checks; + if (!snapshot.has_ptp()) { + checks.push_back({ + "ptp.visibility", + PtpHealthSeverity::fail, + "No valid PTP frame has been observed on the selected interface.", + }); + report.severity = PtpHealthSeverity::fail; + return report; + } + + checks.push_back({ + "ptp.visibility", + PtpHealthSeverity::ok, + "Observed " + std::to_string(snapshot.valid_ptp_frames) + " valid PTP frame(s).", + }); + + std::vector active_sources; + active_sources.reserve(snapshot.sources.size()); + for (const auto& source : snapshot.sources) { + if (snapshot.captured_at >= source.last_seen_at && + snapshot.captured_at - source.last_seen_at <= options.source_timeout) { + active_sources.push_back(&source); + } + } + + if (active_sources.empty()) { + checks.push_back({ + "ptp.liveness", + PtpHealthSeverity::fail, + "No PTP source is within the configured liveness timeout.", + }); + } else { + checks.push_back({ + "ptp.liveness", + PtpHealthSeverity::ok, + std::to_string(active_sources.size()) + " active PTP source(s) are visible.", + }); + } + + if (options.expected_domain_number.has_value()) { + const auto matching = std::count_if(active_sources.begin(), active_sources.end(), [&](const auto* source) { + return source->domain_number == *options.expected_domain_number; + }); + if (matching == 0) { + checks.push_back({ + "ptp.domain", + PtpHealthSeverity::fail, + "No active source is using expected PTP domain " + + std::to_string(*options.expected_domain_number) + ".", + }); + } else { + checks.push_back({ + "ptp.domain", + PtpHealthSeverity::ok, + "PTP domain " + std::to_string(*options.expected_domain_number) + " is visible.", + }); + } + } + + const PtpSourceClockSnapshot* selected = nullptr; + for (const auto* source : active_sources) { + if (options.expected_domain_number.has_value() && + source->domain_number != *options.expected_domain_number) { + continue; + } + if (selected == nullptr || + source->count(PtpMessageType::announce) > selected->count(PtpMessageType::announce) || + (source->count(PtpMessageType::announce) == selected->count(PtpMessageType::announce) && + source->count(PtpMessageType::sync) > selected->count(PtpMessageType::sync)) || + (source->count(PtpMessageType::announce) == selected->count(PtpMessageType::announce) && + source->count(PtpMessageType::sync) == selected->count(PtpMessageType::sync) && + source->last_seen_at > selected->last_seen_at)) { + selected = source; + } + } + + if (selected == nullptr) { + report.severity = report_severity(checks); + return report; + } + report.selected_source = selected->source_port_identity; + + if (options.require_announce) { + add_presence_check(checks, *selected, PtpMessageType::announce, "ptp.announce", "Announce"); + } + if (options.require_sync) { + add_presence_check(checks, *selected, PtpMessageType::sync, "ptp.sync", "Sync"); + } + if (options.require_follow_up_for_two_step) { + add_presence_check(checks, *selected, PtpMessageType::follow_up, "ptp.followup", "Follow_Up"); + } + if (options.require_peer_delay_activity) { + const auto peer_delay_count = + selected->count(PtpMessageType::pdelay_req) + + selected->count(PtpMessageType::pdelay_resp) + + selected->count(PtpMessageType::pdelay_resp_follow_up); + checks.push_back(peer_delay_count != 0U + ? PtpHealthCheck{ + "ptp.pdelay", PtpHealthSeverity::ok, "Peer-delay activity is visible."} + : PtpHealthCheck{ + "ptp.pdelay", PtpHealthSeverity::warning, + "No peer-delay activity is visible; some power-utility profiles expect peer-delay behavior."}); + } + + if (selected->sequence_anomaly_count > options.maximum_sequence_anomalies) { + checks.push_back({ + "ptp.sequence", + PtpHealthSeverity::warning, + "Detected " + std::to_string(selected->sequence_anomaly_count) + + " PTP sequence anomaly/anomalies for " + port_identity_text(selected->source_port_identity) + ".", + }); + } else { + checks.push_back({ + "ptp.sequence", + PtpHealthSeverity::ok, + "No PTP sequence anomaly above threshold.", + }); + } + + report.severity = report_severity(checks); + return report; +} + +SmpSynchValue resolve_smp_synch( + const PtpTimingHealthReport& report, + const bool allow_local_fallback) noexcept { + if (report.is_healthy()) { + return SmpSynchValue::global_synchronized; + } + if (allow_local_fallback && report.snapshot.has_ptp()) { + return SmpSynchValue::local_synchronized; + } + return SmpSynchValue::not_synchronized; +} + +} // namespace ar::iec61850::time_sync From 400e9c4231cef913e9aac6889e949e96ee3c7ea0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:06:19 +0700 Subject: [PATCH 05/39] test(ptp): add C# parity and monitor regression coverage --- tests/test_ptp.cpp | 205 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 tests/test_ptp.cpp diff --git a/tests/test_ptp.cpp b/tests/test_ptp.cpp new file mode 100644 index 000000000..4db167fa8 --- /dev/null +++ b/tests/test_ptp.cpp @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/time_sync/ptp.hpp" +#include "ariec61850/time_sync/ptp_monitor.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +using ByteVector = std::vector; +#define CHECK(condition) do { if (!(condition)) { throw std::runtime_error( \ + std::string{"CHECK failed: "} + #condition + " at " + __FILE__ + ":" + \ + std::to_string(__LINE__)); } } while (false) + +std::string to_hex(const ByteVector& bytes) { + std::ostringstream stream; + stream << std::uppercase << std::hex << std::setfill('0'); + for (const auto byte : bytes) { + stream << std::setw(2) << static_cast(byte); + } + return stream.str(); +} + +ar::iec61850::time_sync::PtpBuildOptions make_options() { + using namespace ar::iec61850::time_sync; + PtpBuildOptions options; + options.domain_number = 0U; + options.source_port_identity.clock_identity = { + 0x02U, 0x00U, 0x00U, 0xFFU, 0xFEU, 0x00U, 0x00U, 0x01U}; + options.source_port_identity.port_number = 1U; + options.sequence_id = 0x1234U; + options.log_message_interval = -2; + options.two_step = true; + options.timestamp = {0x010203040506ULL, 123456789U}; + return options; +} + +void sync_matches_csharp_oracle_and_roundtrips() { + using namespace ar::iec61850::time_sync; + const auto encoded = PtpCodec::build_sync(make_options()); + CHECK(to_hex(encoded) == + "0002002C00000200000000000000000000000000020000FFFE0000010001123400FE" + "010203040506075BCD15"); + + PtpFrame decoded; + CHECK(PtpCodec::try_parse_message(encoded, decoded)); + CHECK(decoded.header.message_type == PtpMessageType::sync); + CHECK(decoded.header.version == 2U); + CHECK(decoded.header.message_length == 44U); + CHECK(decoded.header.domain_number == 0U); + CHECK(decoded.header.is_two_step()); + CHECK(decoded.header.sequence_id == 0x1234U); + CHECK(decoded.header.log_message_interval == -2); + CHECK(decoded.timestamp.has_value()); + CHECK(decoded.timestamp->seconds == 0x010203040506ULL); + CHECK(decoded.timestamp->nanoseconds == 123456789U); +} + +void announce_matches_csharp_oracle_and_roundtrips() { + using namespace ar::iec61850::time_sync; + auto options = make_options(); + options.sequence_id = 7U; + options.log_message_interval = 0; + options.two_step = false; + options.timestamp = {}; + const auto encoded = PtpCodec::build_announce(options, 37); + CHECK(to_hex(encoded) == + "0B02004000000000000000000000000000000000020000FFFE000001000100070500" + "00000000000000000000250080F8FEFFFF80020000FFFE0000010000A0"); + + PtpFrame decoded; + CHECK(PtpCodec::try_parse_message(encoded, decoded)); + CHECK(decoded.header.message_type == PtpMessageType::announce); + CHECK(decoded.announce.has_value()); + CHECK(decoded.announce->current_utc_offset == 37); + CHECK(decoded.announce->priority1 == 128U); + CHECK(decoded.announce->clock_class == 248U); + CHECK(decoded.announce->clock_accuracy == PtpClockAccuracy::unknown); + CHECK(decoded.announce->time_source == PtpTimeSource::internal_oscillator); + CHECK(decoded.announce->grandmaster_identity == options.source_port_identity.clock_identity); +} + +void ethernet_vlan_and_qinq_are_parsed_for_analyzer_use() { + using namespace ar::iec61850::time_sync; + auto options = make_options(); + const auto message = PtpCodec::build_sync(options); + const std::array source{0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x01U}; + const auto vlan_frame = PtpCodec::build_ethernet_frame( + ptp_general_multicast_mac, source, message, 100U, 4U); + CHECK(!vlan_frame.empty()); + + PtpFrame decoded; + CHECK(PtpCodec::try_parse_ethernet_frame(vlan_frame, decoded)); + CHECK(decoded.vlan_id == 100U); + CHECK(!decoded.outer_vlan_id.has_value()); + CHECK(!decoded.peer_delay_multicast); + + ByteVector qinq; + qinq.reserve(vlan_frame.size() + 4U); + qinq.insert(qinq.end(), vlan_frame.begin(), vlan_frame.begin() + 12); + qinq.push_back(0x88U); + qinq.push_back(0xA8U); + qinq.push_back(0xA0U); // PCP 5, VID 10 + qinq.push_back(0x0AU); + qinq.insert(qinq.end(), vlan_frame.begin() + 12, vlan_frame.end()); + + CHECK(PtpCodec::try_parse_ethernet_frame(qinq, decoded)); + CHECK(decoded.outer_vlan_id == 10U); + CHECK(decoded.vlan_id == 100U); + CHECK(decoded.header.sequence_id == 0x1234U); +} + +void passive_monitor_health_drives_conservative_smp_synch_policy() { + using namespace ar::iec61850::time_sync; + const auto observed_at = std::chrono::system_clock::time_point{std::chrono::seconds{1000}}; + const std::array source{0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x01U}; + + auto options = make_options(); + options.timestamp = {}; + options.log_message_interval = 0; + options.sequence_id = 1U; + options.two_step = false; + const auto announce = PtpCodec::build_ethernet_frame( + ptp_general_multicast_mac, source, PtpCodec::build_announce(options)); + + options.two_step = true; + const auto sync = PtpCodec::build_ethernet_frame( + ptp_general_multicast_mac, source, PtpCodec::build_sync(options)); + options.two_step = false; + const auto follow_up = PtpCodec::build_ethernet_frame( + ptp_general_multicast_mac, source, PtpCodec::build_follow_up(options)); + + PtpPassiveMonitor monitor; + CHECK(monitor.observe_ethernet_frame(announce, observed_at)); + CHECK(monitor.observe_ethernet_frame(sync, observed_at + std::chrono::milliseconds{10})); + CHECK(monitor.observe_ethernet_frame(follow_up, observed_at + std::chrono::milliseconds{20})); + + const auto snapshot = monitor.snapshot(observed_at + std::chrono::milliseconds{100}); + PtpTimingHealthOptions health_options; + health_options.expected_domain_number = 0U; + const auto report = PtpTimingHealthValidator::evaluate(snapshot, health_options); + CHECK(report.is_healthy()); + CHECK(report.selected_source.has_value()); + CHECK(resolve_smp_synch(report) == SmpSynchValue::global_synchronized); + + PtpPassiveMonitor incomplete_monitor; + CHECK(incomplete_monitor.observe_ethernet_frame(sync, observed_at)); + const auto incomplete_report = PtpTimingHealthValidator::evaluate( + incomplete_monitor.snapshot(observed_at + std::chrono::milliseconds{100}), + health_options); + CHECK(!incomplete_report.is_healthy()); + CHECK(resolve_smp_synch(incomplete_report, true) == SmpSynchValue::local_synchronized); + CHECK(resolve_smp_synch(incomplete_report, false) == SmpSynchValue::not_synchronized); +} + +void malformed_or_non_ptp_frames_are_rejected() { + using namespace ar::iec61850::time_sync; + PtpFrame frame; + CHECK(!PtpCodec::try_parse_message(ByteVector(33U, 0U), frame)); + + auto message = PtpCodec::build_sync(make_options()); + message[1] = 0x03U; + CHECK(!PtpCodec::try_parse_message(message, frame)); + + std::array ethernet{}; + ethernet[12] = 0x88U; + ethernet[13] = 0xBAU; + CHECK(!PtpCodec::try_parse_ethernet_frame(ethernet, frame)); +} + +} // namespace + +int main() { + const std::vector>> tests{ + {"PTP Sync C# oracle", sync_matches_csharp_oracle_and_roundtrips}, + {"PTP Announce C# oracle", announce_matches_csharp_oracle_and_roundtrips}, + {"PTP VLAN/QinQ analyzer parser", ethernet_vlan_and_qinq_are_parsed_for_analyzer_use}, + {"PTP monitor health and smpSynch", passive_monitor_health_drives_conservative_smp_synch_policy}, + {"PTP malformed input", malformed_or_non_ptp_frames_are_rejected}, + }; + + std::size_t passed = 0U; + for (const auto& [name, test] : tests) { + try { + test(); + ++passed; + std::cout << "[PASS] " << name << '\n'; + } catch (const std::exception& error) { + std::cerr << "[FAIL] " << name << ": " << error.what() << '\n'; + return 1; + } + } + std::cout << "Passed " << passed << "/" << tests.size() << " PTP tests.\n"; + return 0; +} From 873b536f48e444a6da197ba95815a36327e181c8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:09:54 +0700 Subject: [PATCH 06/39] feat(esp32): declare isolated PTP lab broadcaster --- .../esp32p4_smv_injector/main/ptp_lab_task.hpp | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp new file mode 100644 index 000000000..22120ee07 --- /dev/null +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "esp_eth.h" + +namespace ar::esp32p4::smv { + +/** + * Start the optional PTPv2 Layer-2 laboratory broadcaster. + * + * This is a troubleshooting/interoperability timing companion, not a GPS-backed + * or certified grandmaster. The task is independent from the realtime SV hot path. + */ +void ptp_lab_start(esp_eth_handle_t eth_handle); + +} // namespace ar::esp32p4::smv From eb30b9f95cdda7f9ef22a8fa07902798164d400e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:10:43 +0700 Subject: [PATCH 07/39] feat(esp32): add hardware-timestamped PTP lab broadcaster --- .../main/ptp_lab_task.cpp | 306 ++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp new file mode 100644 index 000000000..51b10cc92 --- /dev/null +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -0,0 +1,306 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ptp_lab_task.hpp" + +#include "ariec61850/time_sync/ptp.hpp" + +#include "esp_err.h" +#include "esp_eth.h" +#include "esp_eth_mac.h" +#include "esp_idf_version.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "sdkconfig.h" +#include "soc/soc_caps.h" + +#if ESP_IDF_VERSION_MAJOR < 6 +#include "esp_eth_mac_esp.h" +#endif + +#include +#include +#include +#include +#include +#include + +namespace ar::esp32p4::smv { +namespace { + +using ar::iec61850::time_sync::PtpBuildOptions; +using ar::iec61850::time_sync::PtpClockAccuracy; +using ar::iec61850::time_sync::PtpCodec; +using ar::iec61850::time_sync::PtpTimeSource; +using ar::iec61850::time_sync::PtpTimestamp; +using ar::iec61850::time_sync::ptp_general_multicast_mac; + +constexpr char kTag[] = "ar_ptp_lab"; +constexpr std::uint16_t kPortNumber = 1U; +constexpr std::uint8_t kPriority1 = 128U; +constexpr std::uint8_t kPriority2 = 128U; +constexpr std::uint8_t kClockClass = 248U; +constexpr std::uint16_t kOffsetScaledLogVariance = 0xFFFFU; +constexpr std::int16_t kCurrentUtcOffset = 37; +constexpr TickType_t kStartupDelay = pdMS_TO_TICKS(500); +constexpr TickType_t kRetryDelay = pdMS_TO_TICKS(250); + +struct PtpLabContext final { + esp_eth_handle_t eth_handle{}; + std::array source_mac{}; + PtpBuildOptions base_options{}; + std::uint16_t announce_sequence{}; + std::uint16_t sync_sequence{}; +}; + +PtpLabContext g_ptp_context{}; + +[[nodiscard]] std::int8_t interval_to_log2(const std::uint32_t interval_ms) noexcept { + if (interval_ms == 0U) return 0; + // PTP logMessageInterval is log2(seconds). The configured lab intervals are + // kept to the nearest integer power of two, bounded by the standard byte field. + std::uint32_t numerator = interval_ms; + std::int8_t exponent = 0; + while (numerator < 1000U && exponent > -7) { + numerator *= 2U; + --exponent; + } + while (numerator >= 2000U && exponent < 7) { + numerator = (numerator + 1U) / 2U; + ++exponent; + } + return exponent; +} + +[[nodiscard]] std::array clock_identity_from_mac( + const std::array& mac) noexcept { + return { + mac[0], mac[1], mac[2], 0xFFU, 0xFEU, mac[3], mac[4], mac[5], + }; +} + +#if defined(SOC_EMAC_IEEE1588V2_SUPPORTED) && SOC_EMAC_IEEE1588V2_SUPPORTED && ESP_IDF_VERSION_MAJOR < 6 + +[[nodiscard]] bool enable_hardware_ptp(const esp_eth_handle_t eth_handle) noexcept { + bool enable = true; + const auto result = esp_eth_ioctl( + eth_handle, + static_cast(ETH_MAC_ESP_CMD_PTP_ENABLE), + &enable); + if (result != ESP_OK) { + ESP_LOGE(kTag, "Failed to enable ESP32-P4 IEEE1588 hardware clock: %s", + esp_err_to_name(result)); + return false; + } + return true; +} + +void seed_hardware_clock(const esp_eth_handle_t eth_handle) noexcept { + eth_mac_time_t initial_time{}; + const std::time_t system_time = std::time(nullptr); + if (system_time > 0 && + static_cast(system_time) <= std::numeric_limits::max()) { + initial_time.seconds = static_cast(system_time); + } + const auto result = esp_eth_ioctl( + eth_handle, + static_cast(ETH_MAC_ESP_CMD_S_PTP_TIME), + &initial_time); + if (result != ESP_OK) { + ESP_LOGW(kTag, "Unable to seed hardware PTP time: %s", esp_err_to_name(result)); + } +} + +[[nodiscard]] PtpTimestamp hardware_time(const esp_eth_handle_t eth_handle) noexcept { + eth_mac_time_t timestamp{}; + const auto result = esp_eth_ioctl( + eth_handle, + static_cast(ETH_MAC_ESP_CMD_G_PTP_TIME), + ×tamp); + if (result != ESP_OK || timestamp.nanoseconds >= 1'000'000'000U) { + return {}; + } + return { + static_cast(timestamp.seconds), + timestamp.nanoseconds, + }; +} + +[[nodiscard]] bool send_frame( + const esp_eth_handle_t eth_handle, + std::vector& frame) noexcept { + if (frame.empty()) return false; + return esp_eth_transmit(eth_handle, frame.data(), frame.size()) == ESP_OK; +} + +[[nodiscard]] bool send_sync_follow_up(PtpLabContext& context) noexcept { + auto sync_options = context.base_options; + sync_options.sequence_id = context.sync_sequence; + sync_options.log_message_interval = interval_to_log2(CONFIG_AR_PTP_SYNC_INTERVAL_MS); + sync_options.two_step = true; + // Two-step Sync intentionally carries zero originTimestamp. The exact MAC + // egress timestamp returned by the ESP32-P4 DMA descriptor is carried by + // the subsequent Follow_Up. + sync_options.timestamp = {}; + + const auto sync_message = PtpCodec::build_sync(sync_options); + auto sync_frame = PtpCodec::build_ethernet_frame( + ptp_general_multicast_mac, + context.source_mac, + sync_message); + if (sync_frame.empty()) return false; + + eth_mac_time_t tx_timestamp{}; + const auto sync_result = esp_eth_transmit_ctrl_vargs( + context.eth_handle, + &tx_timestamp, + 2U, + sync_frame.data(), + sync_frame.size()); + if (sync_result != ESP_OK || tx_timestamp.nanoseconds >= 1'000'000'000U) { + return false; + } + + auto follow_up_options = sync_options; + follow_up_options.two_step = false; + follow_up_options.timestamp = { + static_cast(tx_timestamp.seconds), + tx_timestamp.nanoseconds, + }; + const auto follow_up_message = PtpCodec::build_follow_up(follow_up_options); + auto follow_up_frame = PtpCodec::build_ethernet_frame( + ptp_general_multicast_mac, + context.source_mac, + follow_up_message); + if (!send_frame(context.eth_handle, follow_up_frame)) { + return false; + } + + context.sync_sequence = static_cast(context.sync_sequence + 1U); + return true; +} + +[[nodiscard]] bool send_announce(PtpLabContext& context) noexcept { + auto options = context.base_options; + options.sequence_id = context.announce_sequence; + options.log_message_interval = interval_to_log2(CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS); + options.two_step = false; + options.timestamp = hardware_time(context.eth_handle); + + const auto message = PtpCodec::build_announce(options, kCurrentUtcOffset); + auto frame = PtpCodec::build_ethernet_frame( + ptp_general_multicast_mac, + context.source_mac, + message); + if (!send_frame(context.eth_handle, frame)) { + return false; + } + context.announce_sequence = static_cast(context.announce_sequence + 1U); + return true; +} + +void ptp_lab_task(void* argument) { + auto& context = *static_cast(argument); + vTaskDelay(kStartupDelay); + + if (!enable_hardware_ptp(context.eth_handle)) { + ESP_LOGE(kTag, "PTP lab broadcaster stopped: hardware timestamp clock unavailable"); + vTaskDelete(nullptr); + return; + } + seed_hardware_clock(context.eth_handle); + + ESP_ERROR_CHECK(esp_eth_ioctl( + context.eth_handle, + ETH_CMD_G_MAC_ADDR, + context.source_mac.data())); + + context.base_options.domain_number = static_cast(CONFIG_AR_PTP_DOMAIN); + context.base_options.source_port_identity.clock_identity = + clock_identity_from_mac(context.source_mac); + context.base_options.source_port_identity.port_number = kPortNumber; + context.base_options.priority1 = kPriority1; + context.base_options.priority2 = kPriority2; + context.base_options.clock_class = kClockClass; + context.base_options.clock_accuracy = PtpClockAccuracy::unknown; + context.base_options.offset_scaled_log_variance = kOffsetScaledLogVariance; + context.base_options.grandmaster_identity = context.base_options.source_port_identity.clock_identity; + context.base_options.time_source = PtpTimeSource::internal_oscillator; + + ESP_LOGW(kTag, + "PTP LAB TX enabled: troubleshooting/interoperability helper only; not a GPS-backed or certified grandmaster"); + ESP_LOGI(kTag, + "PTP domain=%u Announce=%d ms Sync=%d ms HW two-step TX timestamps=enabled", + static_cast(context.base_options.domain_number), + CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS, + CONFIG_AR_PTP_SYNC_INTERVAL_MS); + + TickType_t next_sync = xTaskGetTickCount(); + TickType_t next_announce = next_sync; + std::uint32_t consecutive_failures = 0U; + + while (true) { + const TickType_t now = xTaskGetTickCount(); + bool attempted = false; + bool success = true; + + if (static_cast(now - next_announce) >= 0) { + attempted = true; + success = send_announce(context) && success; + next_announce = now + pdMS_TO_TICKS(CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS); + } + if (static_cast(now - next_sync) >= 0) { + attempted = true; + success = send_sync_follow_up(context) && success; + next_sync = now + pdMS_TO_TICKS(CONFIG_AR_PTP_SYNC_INTERVAL_MS); + } + + if (attempted) { + if (success) { + consecutive_failures = 0U; + } else { + ++consecutive_failures; + if (consecutive_failures == 1U || (consecutive_failures % 16U) == 0U) { + ESP_LOGW(kTag, + "PTP TX failed (%lu consecutive); Ethernet link/driver may not be ready", + static_cast(consecutive_failures)); + } + } + } + + vTaskDelay(pdMS_TO_TICKS(5)); + } +} + +#else + +void ptp_lab_task(void*) { + ESP_LOGE(kTag, + "PTP lab broadcaster requires ESP32-P4 IEEE1588 support and the ESP-IDF 5.x EMAC timestamp adapter"); + vTaskDelete(nullptr); +} + +#endif + +} // namespace + +void ptp_lab_start(const esp_eth_handle_t eth_handle) { + if (eth_handle == nullptr) { + ESP_LOGE(kTag, "PTP lab broadcaster not started: Ethernet handle is null"); + return; + } + g_ptp_context = {}; + g_ptp_context.eth_handle = eth_handle; + if (xTaskCreatePinnedToCore( + &ptp_lab_task, + "ar_ptp_lab", + 6144, + &g_ptp_context, + 4, + nullptr, + 0) != pdPASS) { + ESP_LOGE(kTag, "Failed to create PTP lab task on CPU0"); + } +} + +} // namespace ar::esp32p4::smv From c713756961989735720daf3d016ba69061336eb4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:11:10 +0700 Subject: [PATCH 08/39] build(esp32): link portable PTP codec into process-bus component --- .../components/arstack_process_bus/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/embedded/esp32p4_smv_injector/components/arstack_process_bus/CMakeLists.txt b/embedded/esp32p4_smv_injector/components/arstack_process_bus/CMakeLists.txt index a5c7a57d5..b1075901e 100644 --- a/embedded/esp32p4_smv_injector/components/arstack_process_bus/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/components/arstack_process_bus/CMakeLists.txt @@ -7,6 +7,7 @@ idf_component_register( "${ARSTACK_ROOT}/src/mms/utc_time.cpp" "${ARSTACK_ROOT}/src/sampled_values/pdu_codec.cpp" "${ARSTACK_ROOT}/src/sampled_values/frame_codec.cpp" + "${ARSTACK_ROOT}/src/time_sync/ptp.cpp" INCLUDE_DIRS "${ARSTACK_ROOT}/include") From bd68f36a4c2ccbfd333edcfade3fe9ac4b88dd17 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:11:19 +0700 Subject: [PATCH 09/39] build(esp32): include PTP lab broadcaster task --- embedded/esp32p4_smv_injector/main/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/embedded/esp32p4_smv_injector/main/CMakeLists.txt b/embedded/esp32p4_smv_injector/main/CMakeLists.txt index bd43d1526..5b2719d31 100644 --- a/embedded/esp32p4_smv_injector/main/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/main/CMakeLists.txt @@ -4,6 +4,7 @@ idf_component_register( "ethernet_port.c" "live_control.cpp" "profile_control.cpp" + "ptp_lab_task.cpp" "runtime_profile.cpp" INCLUDE_DIRS "." REQUIRES arstack_process_bus esp_eth esp_event esp_timer esp_driver_gptimer freertos) From 9fdc9356f980fe973c88114f6069389ce6cf7e86 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:11:44 +0700 Subject: [PATCH 10/39] feat(esp32): add guarded PTP lab broadcaster configuration --- .../main/Kconfig.projbuild | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild index 69aee4aba..dfad84b27 100644 --- a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild +++ b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild @@ -46,6 +46,42 @@ config AR_SMV_DIAGNOSTIC_PROBES Adds low-rate non-SV probes for capture-path troubleshooting. Keep this disabled while measuring realtime timing unless it is needed. +config AR_PTP_LAB_TX + bool "Enable PTPv2 Layer-2 lab timing companion" + default n + help + Enables a low-rate PTPv2 Layer-2 laboratory broadcaster for isolated + troubleshooting, internal testing and interoperability work when a + normal station PTP/GPS time server is unavailable. On ESP32-P4 with + ESP-IDF 5.x the two-step Sync/Follow_Up path uses the EMAC hardware TX + timestamp. This feature is not a GPS-backed or certified grandmaster. + +config AR_PTP_DOMAIN + int "PTP domain number" + range 0 255 + default 0 + depends on AR_PTP_LAB_TX + help + Domain number written into Announce, Sync and Follow_Up messages. + +config AR_PTP_ANNOUNCE_INTERVAL_MS + int "PTP Announce interval (ms)" + range 100 10000 + default 1000 + depends on AR_PTP_LAB_TX + help + Interval for lab Announce traffic. The default matches the existing + ARIEC61850/ARSVIN laboratory publisher behavior. + +config AR_PTP_SYNC_INTERVAL_MS + int "PTP Sync interval (ms)" + range 20 5000 + default 250 + depends on AR_PTP_LAB_TX + help + Interval for two-step Sync/Follow_Up traffic. Follow_Up carries the + ESP32-P4 EMAC transmit timestamp when the IDF 5.x adapter is available. + config AR_SMV_CURRENT_RMS_MA int "Balanced phase current RMS (mA)" range 0 100000 From 72398848e2dd0912251040f14f014451f51441be Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:12:13 +0700 Subject: [PATCH 11/39] fix(sv): make smpSynch default fail-safe --- include/ariec61850/sampled_values/asdu.hpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/include/ariec61850/sampled_values/asdu.hpp b/include/ariec61850/sampled_values/asdu.hpp index cc41e10b6..aae458257 100644 --- a/include/ariec61850/sampled_values/asdu.hpp +++ b/include/ariec61850/sampled_values/asdu.hpp @@ -17,7 +17,9 @@ struct SampledValueAsdu final { std::uint16_t sample_count{}; std::uint32_t configuration_revision{1U}; std::optional reference_time; - std::uint8_t sample_synchronization{2U}; + // Synchronization claims are evidence-driven. New ASDUs therefore start + // unsynchronized and must be promoted explicitly by a validated policy. + std::uint8_t sample_synchronization{0U}; std::optional sample_rate; std::optional sample_mode; std::vector sample_payload; From ac82944607529e3db31b18cf498f73ebc7fe9ab6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:12:59 +0700 Subject: [PATCH 12/39] refactor(esp32): expose C-compatible PTP lab start hook --- .../esp32p4_smv_injector/main/ptp_lab_task.hpp | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp index 22120ee07..996c438f7 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp @@ -4,14 +4,18 @@ #include "esp_eth.h" -namespace ar::esp32p4::smv { +#ifdef __cplusplus +extern "C" { +#endif /** - * Start the optional PTPv2 Layer-2 laboratory broadcaster. + * Start the optional PTPv2 Layer-2 laboratory timing companion. * - * This is a troubleshooting/interoperability timing companion, not a GPS-backed - * or certified grandmaster. The task is independent from the realtime SV hot path. + * This is an isolated troubleshooting/interoperability helper, not a GPS-backed + * or certified grandmaster. The task runs independently from the realtime SV path. */ -void ptp_lab_start(esp_eth_handle_t eth_handle); +void ar_ptp_lab_start(esp_eth_handle_t eth_handle); -} // namespace ar::esp32p4::smv +#ifdef __cplusplus +} +#endif From 84baab63804bc640d58a2c820437c26e7dfac248 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:13:35 +0700 Subject: [PATCH 13/39] refactor(esp32): expose guarded PTP task through C bridge --- .../main/ptp_lab_task.cpp | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index 51b10cc92..59ee9c077 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -22,7 +22,6 @@ #include #include #include -#include #include namespace ar::esp32p4::smv { @@ -43,7 +42,6 @@ constexpr std::uint8_t kClockClass = 248U; constexpr std::uint16_t kOffsetScaledLogVariance = 0xFFFFU; constexpr std::int16_t kCurrentUtcOffset = 37; constexpr TickType_t kStartupDelay = pdMS_TO_TICKS(500); -constexpr TickType_t kRetryDelay = pdMS_TO_TICKS(250); struct PtpLabContext final { esp_eth_handle_t eth_handle{}; @@ -54,11 +52,12 @@ struct PtpLabContext final { }; PtpLabContext g_ptp_context{}; +bool g_ptp_started = false; [[nodiscard]] std::int8_t interval_to_log2(const std::uint32_t interval_ms) noexcept { if (interval_ms == 0U) return 0; // PTP logMessageInterval is log2(seconds). The configured lab intervals are - // kept to the nearest integer power of two, bounded by the standard byte field. + // represented by the nearest useful bounded integer exponent. std::uint32_t numerator = interval_ms; std::int8_t exponent = 0; while (numerator < 1000U && exponent > -7) { @@ -205,15 +204,23 @@ void ptp_lab_task(void* argument) { if (!enable_hardware_ptp(context.eth_handle)) { ESP_LOGE(kTag, "PTP lab broadcaster stopped: hardware timestamp clock unavailable"); + g_ptp_started = false; vTaskDelete(nullptr); return; } seed_hardware_clock(context.eth_handle); - ESP_ERROR_CHECK(esp_eth_ioctl( + const auto mac_result = esp_eth_ioctl( context.eth_handle, ETH_CMD_G_MAC_ADDR, - context.source_mac.data())); + context.source_mac.data()); + if (mac_result != ESP_OK) { + ESP_LOGE(kTag, "PTP lab broadcaster stopped: MAC address unavailable: %s", + esp_err_to_name(mac_result)); + g_ptp_started = false; + vTaskDelete(nullptr); + return; + } context.base_options.domain_number = static_cast(CONFIG_AR_PTP_DOMAIN); context.base_options.source_port_identity.clock_identity = @@ -277,20 +284,23 @@ void ptp_lab_task(void* argument) { void ptp_lab_task(void*) { ESP_LOGE(kTag, "PTP lab broadcaster requires ESP32-P4 IEEE1588 support and the ESP-IDF 5.x EMAC timestamp adapter"); + g_ptp_started = false; vTaskDelete(nullptr); } #endif -} // namespace - -void ptp_lab_start(const esp_eth_handle_t eth_handle) { +void start_ptp_lab(const esp_eth_handle_t eth_handle) { if (eth_handle == nullptr) { ESP_LOGE(kTag, "PTP lab broadcaster not started: Ethernet handle is null"); return; } + if (g_ptp_started) { + return; + } g_ptp_context = {}; g_ptp_context.eth_handle = eth_handle; + g_ptp_started = true; if (xTaskCreatePinnedToCore( &ptp_lab_task, "ar_ptp_lab", @@ -299,8 +309,14 @@ void ptp_lab_start(const esp_eth_handle_t eth_handle) { 4, nullptr, 0) != pdPASS) { + g_ptp_started = false; ESP_LOGE(kTag, "Failed to create PTP lab task on CPU0"); } } +} // namespace } // namespace ar::esp32p4::smv + +extern "C" void ar_ptp_lab_start(const esp_eth_handle_t eth_handle) { + ar::esp32p4::smv::start_ptp_lab(eth_handle); +} From ffc5f8a12b3ae74e711e1f66f522e6e207b7f9e9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:14:01 +0700 Subject: [PATCH 14/39] build(esp32): compile PTP task only when lab timing is enabled --- embedded/esp32p4_smv_injector/main/CMakeLists.txt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/embedded/esp32p4_smv_injector/main/CMakeLists.txt b/embedded/esp32p4_smv_injector/main/CMakeLists.txt index 5b2719d31..ff51bf7a5 100644 --- a/embedded/esp32p4_smv_injector/main/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/main/CMakeLists.txt @@ -1,11 +1,16 @@ +set(AR_PTP_LAB_SRCS) +if(CONFIG_AR_PTP_LAB_TX) + list(APPEND AR_PTP_LAB_SRCS "ptp_lab_task.cpp") +endif() + idf_component_register( SRCS "app_main.cpp" "ethernet_port.c" "live_control.cpp" "profile_control.cpp" - "ptp_lab_task.cpp" "runtime_profile.cpp" + ${AR_PTP_LAB_SRCS} INCLUDE_DIRS "." REQUIRES arstack_process_bus esp_eth esp_event esp_timer esp_driver_gptimer freertos) From 7803915006337aaf9c4006eadcc653cdaf2b7af4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:14:20 +0700 Subject: [PATCH 15/39] feat(esp32): attach optional PTP lab timing companion to EMAC --- embedded/esp32p4_smv_injector/main/ethernet_port.c | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/ethernet_port.c b/embedded/esp32p4_smv_injector/main/ethernet_port.c index 93000d9a2..936d64843 100644 --- a/embedded/esp32p4_smv_injector/main/ethernet_port.c +++ b/embedded/esp32p4_smv_injector/main/ethernet_port.c @@ -5,6 +5,11 @@ #include "esp_err.h" #include "esp_eth_mac_esp.h" #include "esp_log.h" +#include "sdkconfig.h" + +#if CONFIG_AR_PTP_LAB_TX +#include "ptp_lab_task.hpp" +#endif static const char *TAG = "ar_smv_eth"; @@ -69,6 +74,13 @@ esp_eth_handle_t ar_esp32p4_eth_init(void) return NULL; } +#if CONFIG_AR_PTP_LAB_TX + // The PTP task starts independently and tolerates link-not-ready TX failures + // while the normal app flow proceeds to esp_eth_start(). Keeping this hook + // beside driver installation also allows future PTP-only/analyzer firmware. + ar_ptp_lab_start(handle); +#endif + ESP_LOGI(TAG, "Ethernet configured: PHY addr=%d MDC=%d MDIO=%d REF_CLK=%d", AR_PHY_ADDRESS, AR_MDC_GPIO, AR_MDIO_GPIO, AR_RMII_REF_CLK_GPIO); From d5525406a75f5430f816b4d7fe57570d4c702575 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:15:08 +0700 Subject: [PATCH 16/39] ci(ptp): add strict portable PTP core regression profile --- .github/workflows/ptp-core-ci.yml | 55 +++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 .github/workflows/ptp-core-ci.yml diff --git a/.github/workflows/ptp-core-ci.yml b/.github/workflows/ptp-core-ci.yml new file mode 100644 index 000000000..eadefcba2 --- /dev/null +++ b/.github/workflows/ptp-core-ci.yml @@ -0,0 +1,55 @@ +name: PTP Core CI + +on: + push: + paths: + - 'include/ariec61850/time_sync/**' + - 'src/time_sync/**' + - 'include/ariec61850/ethernet/**' + - 'src/ethernet/**' + - 'tests/test_ptp.cpp' + - '.github/workflows/ptp-core-ci.yml' + pull_request: + paths: + - 'include/ariec61850/time_sync/**' + - 'src/time_sync/**' + - 'include/ariec61850/ethernet/**' + - 'src/ethernet/**' + - 'tests/test_ptp.cpp' + - '.github/workflows/ptp-core-ci.yml' + +permissions: + contents: read + +jobs: + portable-ptp: + strategy: + fail-fast: false + matrix: + compiler: [gcc, clang] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build PTP strict profile + shell: bash + run: | + if [ "${{ matrix.compiler }}" = "clang" ]; then + CXX=clang++ + else + CXX=g++ + fi + "$CXX" \ + -std=c++20 \ + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror \ + -pthread \ + -Iinclude \ + src/ethernet/ethernet.cpp \ + src/time_sync/ptp.cpp \ + src/time_sync/ptp_monitor.cpp \ + tests/test_ptp.cpp \ + -o ptp-core-tests + + - name: Run PTP regression tests + run: ./ptp-core-tests From c71cb90311993baf0e04dbca1b0a4e2f947e767d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:16:41 +0700 Subject: [PATCH 17/39] feat(ptp): add read-only PCAP analyzer CLI --- tools/ptp_analyze.cpp | 190 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 tools/ptp_analyze.cpp diff --git a/tools/ptp_analyze.cpp b/tools/ptp_analyze.cpp new file mode 100644 index 000000000..5916a30f1 --- /dev/null +++ b/tools/ptp_analyze.cpp @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/capture/pcap.hpp" +#include "ariec61850/time_sync/ptp.hpp" +#include "ariec61850/time_sync/ptp_monitor.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using ar::iec61850::time_sync::PtpHealthSeverity; +using ar::iec61850::time_sync::PtpMessageType; +using ar::iec61850::time_sync::PtpPortIdentity; + +[[nodiscard]] const char* severity_text(const PtpHealthSeverity severity) noexcept { + switch (severity) { + case PtpHealthSeverity::ok: return "ok"; + case PtpHealthSeverity::warning: return "warning"; + case PtpHealthSeverity::fail: return "fail"; + } + return "unknown"; +} + +[[nodiscard]] std::string port_identity_text(const PtpPortIdentity& identity) { + std::ostringstream stream; + stream << std::uppercase << std::hex << std::setfill('0'); + for (std::size_t index = 0U; index < identity.clock_identity.size(); ++index) { + if (index != 0U) stream << ':'; + stream << std::setw(2) << static_cast(identity.clock_identity[index]); + } + stream << std::dec << '/' << identity.port_number; + return stream.str(); +} + +[[nodiscard]] std::uint8_t parse_domain(const std::string_view text) { + std::size_t consumed{}; + const auto value = std::stoul(std::string{text}, &consumed, 0); + if (consumed != text.size() || value > 255UL) { + throw std::invalid_argument("PTP domain must be in the range 0..255."); + } + return static_cast(value); +} + +void print_help() { + std::cout + << "Usage: ariec61850_ptp_analyze [--domain N] [--json]\n" + << "\n" + << "Read-only PTPv2 Layer-2 analyzer for laboratory captures.\n" + << "It reports source/domain/message visibility and sequence health;\n" + << "it does not claim measured clock accuracy from host capture timestamps.\n"; +} + +} // namespace + +int main(int argc, char** argv) { + try { + if (argc < 2) { + print_help(); + return 2; + } + + std::filesystem::path capture_path; + std::optional expected_domain; + bool json = false; + + for (int index = 1; index < argc; ++index) { + const std::string_view argument{argv[index]}; + if (argument == "--help" || argument == "-h") { + print_help(); + return 0; + } + if (argument == "--json") { + json = true; + continue; + } + if (argument == "--domain") { + if (index + 1 >= argc) { + throw std::invalid_argument("--domain requires a value."); + } + expected_domain = parse_domain(argv[++index]); + continue; + } + if (!capture_path.empty()) { + throw std::invalid_argument("Unexpected argument: " + std::string{argument}); + } + capture_path = std::filesystem::path{argument}; + } + + if (capture_path.empty()) { + throw std::invalid_argument("A PCAP capture path is required."); + } + + const auto packets = ar::iec61850::capture::PcapReader::read_all(capture_path); + ar::iec61850::time_sync::PtpPassiveMonitor monitor; + std::uint64_t ptp_frames = 0U; + std::uint64_t other_frames = 0U; + + for (const auto& packet : packets) { + ar::iec61850::time_sync::PtpFrame frame; + if (ar::iec61850::time_sync::PtpCodec::try_parse_ethernet_frame(packet.frame, frame)) { + monitor.observe(frame, packet.timestamp); + ++ptp_frames; + } else { + ++other_frames; + } + } + + const auto captured_at = packets.empty() + ? std::chrono::system_clock::now() + : packets.back().timestamp; + const auto snapshot = monitor.snapshot(captured_at); + ar::iec61850::time_sync::PtpTimingHealthOptions options; + options.expected_domain_number = expected_domain; + const auto report = ar::iec61850::time_sync::PtpTimingHealthValidator::evaluate(snapshot, options); + + if (json) { + std::cout << '{' + << "\"capturePackets\":" << packets.size() << ',' + << "\"ptpFrames\":" << ptp_frames << ',' + << "\"otherFrames\":" << other_frames << ',' + << "\"health\":\"" << severity_text(report.severity) << "\"," + << "\"sources\":["; + for (std::size_t index = 0U; index < snapshot.sources.size(); ++index) { + if (index != 0U) std::cout << ','; + const auto& source = snapshot.sources[index]; + std::cout << '{' + << "\"domain\":" << static_cast(source.domain_number) << ',' + << "\"portIdentity\":\"" << port_identity_text(source.source_port_identity) << "\"," + << "\"announce\":" << source.count(PtpMessageType::announce) << ',' + << "\"sync\":" << source.count(PtpMessageType::sync) << ',' + << "\"followUp\":" << source.count(PtpMessageType::follow_up) << ',' + << "\"pdelayReq\":" << source.count(PtpMessageType::pdelay_req) << ',' + << "\"pdelayResp\":" << source.count(PtpMessageType::pdelay_resp) << ',' + << "\"pdelayRespFollowUp\":" << source.count(PtpMessageType::pdelay_resp_follow_up) << ',' + << "\"sequenceAnomalies\":" << source.sequence_anomaly_count; + if (source.outer_vlan_id.has_value()) { + std::cout << ",\"outerVlan\":" << *source.outer_vlan_id; + } + if (source.vlan_id.has_value()) { + std::cout << ",\"vlan\":" << *source.vlan_id; + } + std::cout << '}'; + } + std::cout << "],\"checks\":["; + for (std::size_t index = 0U; index < report.checks.size(); ++index) { + if (index != 0U) std::cout << ','; + const auto& check = report.checks[index]; + std::cout << '{' + << "\"id\":\"" << check.id << "\"," + << "\"severity\":\"" << severity_text(check.severity) << "\""; + std::cout << '}'; + } + std::cout << "]}\n"; + return report.severity == PtpHealthSeverity::fail ? 1 : 0; + } + + std::cout << "PTP capture: packets=" << packets.size() + << " ptp=" << ptp_frames + << " other=" << other_frames + << " health=" << severity_text(report.severity) << '\n'; + for (const auto& source : snapshot.sources) { + std::cout << " domain=" << static_cast(source.domain_number) + << " source=" << port_identity_text(source.source_port_identity) + << " announce=" << source.count(PtpMessageType::announce) + << " sync=" << source.count(PtpMessageType::sync) + << " followUp=" << source.count(PtpMessageType::follow_up) + << " pdelayReq=" << source.count(PtpMessageType::pdelay_req) + << " pdelayResp=" << source.count(PtpMessageType::pdelay_resp) + << " seqAnomaly=" << source.sequence_anomaly_count + << '\n'; + } + for (const auto& check : report.checks) { + std::cout << " [" << severity_text(check.severity) << "] " + << check.id << ": " << check.message << '\n'; + } + return report.severity == PtpHealthSeverity::fail ? 1 : 0; + } catch (const std::exception& error) { + std::cerr << "PTP analyzer error: " << error.what() << '\n'; + return 2; + } +} From 017188fcea07c4cb83c9a640529b5d3e0dc46175 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:17:56 +0700 Subject: [PATCH 18/39] build(ptp): integrate PTP core tests and analyzer into primary build --- CMakeLists.txt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 576c9c51e..583c98f22 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,6 +14,8 @@ option(ARIEC61850_WARNINGS_AS_ERRORS "Treat compiler warnings as errors" ON) add_library(ariec61850_core src/asn1/ber.cpp src/ethernet/ethernet.cpp + src/time_sync/ptp.cpp + src/time_sync/ptp_monitor.cpp src/capture/pcap.cpp src/goose/retransmission_schedule.cpp src/goose/pdu_codec.cpp @@ -113,6 +115,12 @@ if(ARIEC61850_BUILD_TOOLS) ariec61850_apply_warnings(ariec61850_pcap_interop_check) ariec61850_apply_sanitizers(ariec61850_pcap_interop_check) + add_executable(ariec61850_ptp_analyze tools/ptp_analyze.cpp) + target_link_libraries(ariec61850_ptp_analyze PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_ptp_analyze PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_ptp_analyze) + ariec61850_apply_sanitizers(ariec61850_ptp_analyze) + add_executable(ariec61850_comtrade_inspect tools/comtrade_inspect.cpp) target_link_libraries(ariec61850_comtrade_inspect PRIVATE ARIEC61850::core) target_compile_features(ariec61850_comtrade_inspect PRIVATE cxx_std_20) @@ -161,7 +169,7 @@ if(ARIEC61850_BUILD_TESTS) ariec61850_apply_sanitizers(ariec61850_core_tests) add_test(NAME ariec61850_core_tests COMMAND ariec61850_core_tests) - foreach(test_name IN ITEMS mms goose sampled_values) + foreach(test_name IN ITEMS mms goose sampled_values ptp) add_executable(ariec61850_${test_name}_tests tests/test_${test_name}.cpp) target_link_libraries(ariec61850_${test_name}_tests PRIVATE ARIEC61850::core) target_compile_features(ariec61850_${test_name}_tests PRIVATE cxx_std_20) @@ -340,6 +348,9 @@ if(ARIEC61850_BUILD_TESTS) COMMAND ariec61850_comtrade_inspect ${CMAKE_CURRENT_SOURCE_DIR}/tests/fixtures/comtrade/simple_fault.cfg --json) + add_test( + NAME ariec61850_ptp_analyze_help + COMMAND ariec61850_ptp_analyze --help) add_test( NAME ariec61850_pagination_probe_help COMMAND ariec61850_pagination_probe --help) From 5fb63b73376f3afe7e81231539569d87dd2ad61e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:20:29 +0700 Subject: [PATCH 19/39] ci(ptp): surface strict compiler diagnostics as annotations --- .github/workflows/ptp-core-ci.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ptp-core-ci.yml b/.github/workflows/ptp-core-ci.yml index eadefcba2..dc7db4c59 100644 --- a/.github/workflows/ptp-core-ci.yml +++ b/.github/workflows/ptp-core-ci.yml @@ -40,6 +40,8 @@ jobs: else CXX=g++ fi + + set +e "$CXX" \ -std=c++20 \ -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror \ @@ -49,7 +51,21 @@ jobs: src/time_sync/ptp.cpp \ src/time_sync/ptp_monitor.cpp \ tests/test_ptp.cpp \ - -o ptp-core-tests + -o ptp-core-tests \ + >ptp-build.log 2>&1 + status=$? + set -e + + cat ptp-build.log + if [ "$status" -ne 0 ]; then + while IFS= read -r line; do + escaped=${line//'%'/'%25'} + escaped=${escaped//$'\r'/'%0D'} + escaped=${escaped//$'\n'/'%0A'} + echo "::error::$escaped" + done < ptp-build.log + exit "$status" + fi - name: Run PTP regression tests run: ./ptp-core-tests From 975ef6ad07485ed7700f29b7b5c8abad97292e72 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:23:01 +0700 Subject: [PATCH 20/39] fix(ptp): qualify chrono nanoseconds in timestamp conversion --- src/time_sync/ptp.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/time_sync/ptp.cpp b/src/time_sync/ptp.cpp index 4ce2c9637..073d8aef6 100644 --- a/src/time_sync/ptp.cpp +++ b/src/time_sync/ptp.cpp @@ -5,6 +5,7 @@ #include #include #include +#include namespace ar::iec61850::time_sync { namespace { @@ -105,8 +106,8 @@ void write_u64_be(const std::span destination, const std::uint64_t } // namespace PtpTimestamp PtpTimestamp::from_system_time(const std::chrono::system_clock::time_point time) noexcept { - using namespace std::chrono; - const auto elapsed = duration_cast(time.time_since_epoch()).count(); + const auto elapsed = std::chrono::duration_cast( + time.time_since_epoch()).count(); if (elapsed <= 0) { return {}; } @@ -230,8 +231,8 @@ std::vector PtpCodec::build_ethernet_frame( } ethernet::EthernetFrame frame; - frame.destination = ethernet::MacAddress{destination_mac}; - frame.source = ethernet::MacAddress{source_mac}; + frame.destination = ethernet::MacAddress{std::span{destination_mac}}; + frame.source = ethernet::MacAddress{std::span{source_mac}}; frame.ether_type = ptp_ethertype; if (vlan_id.has_value()) { frame.vlan = ethernet::VlanTag{vlan_priority, *vlan_id}; From c3168a66d61d156cef8e1dc063e6ff59e0dd1923 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:25:39 +0700 Subject: [PATCH 21/39] ci(ptp): surface regression failures as annotations --- .github/workflows/ptp-core-ci.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ptp-core-ci.yml b/.github/workflows/ptp-core-ci.yml index dc7db4c59..5942e7d8c 100644 --- a/.github/workflows/ptp-core-ci.yml +++ b/.github/workflows/ptp-core-ci.yml @@ -68,4 +68,19 @@ jobs: fi - name: Run PTP regression tests - run: ./ptp-core-tests + shell: bash + run: | + set +e + ./ptp-core-tests >ptp-test.log 2>&1 + status=$? + set -e + cat ptp-test.log + if [ "$status" -ne 0 ]; then + while IFS= read -r line; do + escaped=${line//'%'/'%25'} + escaped=${escaped//$'\r'/'%0D'} + escaped=${escaped//$'\n'/'%0A'} + echo "::error::$escaped" + done < ptp-test.log + exit "$status" + fi From ab88aeb0257c32e7727f4e7c65d9cadb66c117db Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:26:15 +0700 Subject: [PATCH 22/39] ci(ptp): compile ESP32-P4 hardware timestamp lab profile --- .github/workflows/esp32p4-ptp-lab.yml | 52 +++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .github/workflows/esp32p4-ptp-lab.yml diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml new file mode 100644 index 000000000..9b42df7a2 --- /dev/null +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -0,0 +1,52 @@ +name: ESP32-P4 PTP Lab Profile + +on: + push: + paths: + - 'embedded/esp32p4_smv_injector/**' + - 'include/ariec61850/time_sync/**' + - 'src/time_sync/**' + - 'include/ariec61850/ethernet/**' + - 'src/ethernet/**' + - '.github/workflows/esp32p4-ptp-lab.yml' + pull_request: + paths: + - 'embedded/esp32p4_smv_injector/**' + - 'include/ariec61850/time_sync/**' + - 'src/time_sync/**' + - 'include/ariec61850/ethernet/**' + - 'src/ethernet/**' + - '.github/workflows/esp32p4-ptp-lab.yml' + +permissions: + contents: read + +jobs: + build-esp32p4-ptp: + runs-on: ubuntu-latest + container: + image: espressif/idf:v5.5.4 + defaults: + run: + shell: bash + working-directory: embedded/esp32p4_smv_injector + steps: + - uses: actions/checkout@v4 + + - name: Select ESP32-P4 target + run: source /opt/esp/idf/export.sh && idf.py set-target esp32p4 + + - name: Enable guarded PTP lab profile + shell: bash + run: | + if grep -q '^# CONFIG_AR_PTP_LAB_TX is not set$' sdkconfig; then + sed -i 's/^# CONFIG_AR_PTP_LAB_TX is not set$/CONFIG_AR_PTP_LAB_TX=y/' sdkconfig + elif ! grep -q '^CONFIG_AR_PTP_LAB_TX=y$' sdkconfig; then + echo 'CONFIG_AR_PTP_LAB_TX=y' >> sdkconfig + fi + source /opt/esp/idf/export.sh + idf.py reconfigure + grep -E '^CONFIG_AR_PTP_(LAB_TX|DOMAIN|ANNOUNCE_INTERVAL_MS|SYNC_INTERVAL_MS)=' sdkconfig + + - name: Build PTP-enabled firmware + run: source /opt/esp/idf/export.sh && idf.py build From be41bbb11014dd26ec5182eefd609f5219129534 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:27:53 +0700 Subject: [PATCH 23/39] test(ptp): correct C# Announce reserved-byte golden vector --- tests/test_ptp.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_ptp.cpp b/tests/test_ptp.cpp index 4db167fa8..e3dd8f3ed 100644 --- a/tests/test_ptp.cpp +++ b/tests/test_ptp.cpp @@ -76,7 +76,7 @@ void announce_matches_csharp_oracle_and_roundtrips() { const auto encoded = PtpCodec::build_announce(options, 37); CHECK(to_hex(encoded) == "0B02004000000000000000000000000000000000020000FFFE000001000100070500" - "00000000000000000000250080F8FEFFFF80020000FFFE0000010000A0"); + "0000000000000000000000250080F8FEFFFF80020000FFFE0000010000A0"); PtpFrame decoded; CHECK(PtpCodec::try_parse_message(encoded, decoded)); From 689d8e423971f91999d5aa0d40741fd360d5e108 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:31:49 +0700 Subject: [PATCH 24/39] ci(ptp): add strict MSVC PTP build and regression gate --- .github/workflows/ptp-core-ci.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.github/workflows/ptp-core-ci.yml b/.github/workflows/ptp-core-ci.yml index 5942e7d8c..53871f0cd 100644 --- a/.github/workflows/ptp-core-ci.yml +++ b/.github/workflows/ptp-core-ci.yml @@ -84,3 +84,31 @@ jobs: done < ptp-test.log exit "$status" fi + + portable-ptp-msvc: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure strict MSVC build + shell: pwsh + run: cmake -S . -B build-ptp-msvc -A x64 -DARIEC61850_BUILD_TESTS=ON -DARIEC61850_BUILD_TOOLS=ON -DARIEC61850_WARNINGS_AS_ERRORS=ON + + - name: Build with diagnostics + shell: pwsh + run: | + $output = & cmake --build build-ptp-msvc --config Release --parallel 2>&1 + $status = $LASTEXITCODE + $output | Tee-Object -FilePath ptp-msvc-build.log + if ($status -ne 0) { + foreach ($line in $output) { + $escaped = "$line".Replace('%','%25').Replace("`r",'%0D').Replace("`n",'%0A') + Write-Output "::error::$escaped" + } + exit $status + } + + - name: Run PTP regression test on MSVC + shell: pwsh + run: ctest --test-dir build-ptp-msvc -C Release -R ariec61850_ptp_tests --output-on-failure From 7ae7f87bc91abdd9be8110d0a87240862ba6e357 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:37:03 +0700 Subject: [PATCH 25/39] feat(esp32): answer Pdelay with hardware RX and TX timestamps --- .../main/ptp_lab_task.cpp | 206 ++++++++++++++++-- 1 file changed, 184 insertions(+), 22 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index 59ee9c077..cb789d4be 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -6,10 +6,12 @@ #include "esp_err.h" #include "esp_eth.h" +#include "esp_eth_driver.h" #include "esp_eth_mac.h" #include "esp_idf_version.h" #include "esp_log.h" #include "freertos/FreeRTOS.h" +#include "freertos/queue.h" #include "freertos/task.h" #include "sdkconfig.h" #include "soc/soc_caps.h" @@ -20,6 +22,7 @@ #include #include +#include #include #include #include @@ -30,9 +33,13 @@ namespace { using ar::iec61850::time_sync::PtpBuildOptions; using ar::iec61850::time_sync::PtpClockAccuracy; using ar::iec61850::time_sync::PtpCodec; +using ar::iec61850::time_sync::PtpFrame; +using ar::iec61850::time_sync::PtpMessageType; +using ar::iec61850::time_sync::PtpPortIdentity; using ar::iec61850::time_sync::PtpTimeSource; using ar::iec61850::time_sync::PtpTimestamp; using ar::iec61850::time_sync::ptp_general_multicast_mac; +using ar::iec61850::time_sync::ptp_peer_delay_multicast_mac; constexpr char kTag[] = "ar_ptp_lab"; constexpr std::uint16_t kPortNumber = 1U; @@ -42,13 +49,24 @@ constexpr std::uint8_t kClockClass = 248U; constexpr std::uint16_t kOffsetScaledLogVariance = 0xFFFFU; constexpr std::int16_t kCurrentUtcOffset = 37; constexpr TickType_t kStartupDelay = pdMS_TO_TICKS(500); +constexpr UBaseType_t kPdelayQueueDepth = 8U; + +struct PdelayRequestEvent final { + PtpPortIdentity requesting_port_identity{}; + PtpTimestamp receive_timestamp{}; + std::uint16_t sequence_id{}; + std::int8_t log_message_interval{}; +}; struct PtpLabContext final { esp_eth_handle_t eth_handle{}; std::array source_mac{}; PtpBuildOptions base_options{}; + QueueHandle_t pdelay_queue{}; + TaskHandle_t task_handle{}; std::uint16_t announce_sequence{}; std::uint16_t sync_sequence{}; + std::uint64_t pdelay_responses{}; }; PtpLabContext g_ptp_context{}; @@ -80,6 +98,18 @@ bool g_ptp_started = false; #if defined(SOC_EMAC_IEEE1588V2_SUPPORTED) && SOC_EMAC_IEEE1588V2_SUPPORTED && ESP_IDF_VERSION_MAJOR < 6 +[[nodiscard]] bool valid_hw_timestamp(const eth_mac_time_t& timestamp) noexcept { + return timestamp.nanoseconds < 1'000'000'000U && + (timestamp.seconds != 0U || timestamp.nanoseconds != 0U); +} + +[[nodiscard]] PtpTimestamp to_ptp_timestamp(const eth_mac_time_t& timestamp) noexcept { + return { + static_cast(timestamp.seconds), + timestamp.nanoseconds, + }; +} + [[nodiscard]] bool enable_hardware_ptp(const esp_eth_handle_t eth_handle) noexcept { bool enable = true; const auto result = esp_eth_ioctl( @@ -119,10 +149,7 @@ void seed_hardware_clock(const esp_eth_handle_t eth_handle) noexcept { if (result != ESP_OK || timestamp.nanoseconds >= 1'000'000'000U) { return {}; } - return { - static_cast(timestamp.seconds), - timestamp.nanoseconds, - }; + return to_ptp_timestamp(timestamp); } [[nodiscard]] bool send_frame( @@ -132,6 +159,25 @@ void seed_hardware_clock(const esp_eth_handle_t eth_handle) noexcept { return esp_eth_transmit(eth_handle, frame.data(), frame.size()) == ESP_OK; } +[[nodiscard]] bool transmit_with_hw_timestamp( + const esp_eth_handle_t eth_handle, + std::vector& frame, + PtpTimestamp& timestamp) noexcept { + if (frame.empty()) return false; + eth_mac_time_t tx_timestamp{}; + const auto result = esp_eth_transmit_ctrl_vargs( + eth_handle, + &tx_timestamp, + 2U, + frame.data(), + frame.size()); + if (result != ESP_OK || !valid_hw_timestamp(tx_timestamp)) { + return false; + } + timestamp = to_ptp_timestamp(tx_timestamp); + return true; +} + [[nodiscard]] bool send_sync_follow_up(PtpLabContext& context) noexcept { auto sync_options = context.base_options; sync_options.sequence_id = context.sync_sequence; @@ -147,25 +193,15 @@ void seed_hardware_clock(const esp_eth_handle_t eth_handle) noexcept { ptp_general_multicast_mac, context.source_mac, sync_message); - if (sync_frame.empty()) return false; - eth_mac_time_t tx_timestamp{}; - const auto sync_result = esp_eth_transmit_ctrl_vargs( - context.eth_handle, - &tx_timestamp, - 2U, - sync_frame.data(), - sync_frame.size()); - if (sync_result != ESP_OK || tx_timestamp.nanoseconds >= 1'000'000'000U) { + PtpTimestamp tx_timestamp; + if (!transmit_with_hw_timestamp(context.eth_handle, sync_frame, tx_timestamp)) { return false; } auto follow_up_options = sync_options; follow_up_options.two_step = false; - follow_up_options.timestamp = { - static_cast(tx_timestamp.seconds), - tx_timestamp.nanoseconds, - }; + follow_up_options.timestamp = tx_timestamp; const auto follow_up_message = PtpCodec::build_follow_up(follow_up_options); auto follow_up_frame = PtpCodec::build_ethernet_frame( ptp_general_multicast_mac, @@ -198,6 +234,95 @@ void seed_hardware_clock(const esp_eth_handle_t eth_handle) noexcept { return true; } +[[nodiscard]] bool send_pdelay_response( + PtpLabContext& context, + const PdelayRequestEvent& request) noexcept { + auto response_options = context.base_options; + response_options.sequence_id = request.sequence_id; + response_options.log_message_interval = request.log_message_interval; + response_options.two_step = true; + // t2: hardware receive timestamp of Pdelay_Req. + response_options.timestamp = request.receive_timestamp; + + const auto response_message = PtpCodec::build_pdelay_resp( + response_options, + request.requesting_port_identity); + auto response_frame = PtpCodec::build_ethernet_frame( + ptp_peer_delay_multicast_mac, + context.source_mac, + response_message); + + PtpTimestamp response_tx_timestamp; + if (!transmit_with_hw_timestamp( + context.eth_handle, + response_frame, + response_tx_timestamp)) { + return false; + } + + auto follow_up_options = response_options; + follow_up_options.two_step = false; + // t3: hardware transmit timestamp of Pdelay_Resp. + follow_up_options.timestamp = response_tx_timestamp; + const auto follow_up_message = PtpCodec::build_pdelay_resp_follow_up( + follow_up_options, + request.requesting_port_identity); + auto follow_up_frame = PtpCodec::build_ethernet_frame( + ptp_peer_delay_multicast_mac, + context.source_mac, + follow_up_message); + if (!send_frame(context.eth_handle, follow_up_frame)) { + return false; + } + + ++context.pdelay_responses; + if (context.pdelay_responses == 1U) { + ESP_LOGI(kTag, "First hardware-timestamped Pdelay exchange completed"); + } + return true; +} + +esp_err_t ptp_input_info( + esp_eth_handle_t, + std::uint8_t* buffer, + const std::uint32_t length, + void* priv, + void* info) { + auto* context = static_cast(priv); + if (buffer == nullptr) { + return ESP_OK; + } + + if (context != nullptr && context->pdelay_queue != nullptr && info != nullptr) { + PtpFrame frame; + if (PtpCodec::try_parse_ethernet_frame( + std::span{buffer, length}, + frame) && + frame.header.message_type == PtpMessageType::pdelay_req && + frame.header.domain_number == context->base_options.domain_number) { + const auto& rx_timestamp = *static_cast(info); + if (valid_hw_timestamp(rx_timestamp)) { + const PdelayRequestEvent event{ + frame.header.source_port_identity, + to_ptp_timestamp(rx_timestamp), + frame.header.sequence_id, + frame.header.log_message_interval, + }; + if (xQueueSend(context->pdelay_queue, &event, 0U) == pdTRUE && + context->task_handle != nullptr) { + xTaskNotifyGive(context->task_handle); + } + } + } + } + + // The injector is a standalone raw-L2 application and currently has no + // competing TCP/IP input consumer. This registered input path owns the RX + // buffer and must release it after inspection. + std::free(buffer); + return ESP_OK; +} + void ptp_lab_task(void* argument) { auto& context = *static_cast(argument); vTaskDelay(kStartupDelay); @@ -234,23 +359,46 @@ void ptp_lab_task(void* argument) { context.base_options.grandmaster_identity = context.base_options.source_port_identity.clock_identity; context.base_options.time_source = PtpTimeSource::internal_oscillator; + bool pdelay_enabled = false; + if (context.pdelay_queue != nullptr) { + const auto input_result = esp_eth_update_input_path_info( + context.eth_handle, + &ptp_input_info, + &context); + if (input_result == ESP_OK) { + pdelay_enabled = true; + } else { + ESP_LOGW(kTag, + "Hardware RX timestamp path unavailable; Pdelay responder disabled: %s", + esp_err_to_name(input_result)); + } + } + ESP_LOGW(kTag, "PTP LAB TX enabled: troubleshooting/interoperability helper only; not a GPS-backed or certified grandmaster"); ESP_LOGI(kTag, - "PTP domain=%u Announce=%d ms Sync=%d ms HW two-step TX timestamps=enabled", + "PTP domain=%u Announce=%d ms Sync=%d ms HW Sync TX timestamp=enabled HW Pdelay=%s", static_cast(context.base_options.domain_number), CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS, - CONFIG_AR_PTP_SYNC_INTERVAL_MS); + CONFIG_AR_PTP_SYNC_INTERVAL_MS, + pdelay_enabled ? "enabled" : "disabled"); TickType_t next_sync = xTaskGetTickCount(); TickType_t next_announce = next_sync; std::uint32_t consecutive_failures = 0U; while (true) { - const TickType_t now = xTaskGetTickCount(); bool attempted = false; bool success = true; + PdelayRequestEvent pdelay_request; + while (context.pdelay_queue != nullptr && + xQueueReceive(context.pdelay_queue, &pdelay_request, 0U) == pdTRUE) { + attempted = true; + success = send_pdelay_response(context, pdelay_request) && success; + } + + const TickType_t now = xTaskGetTickCount(); if (static_cast(now - next_announce) >= 0) { attempted = true; success = send_announce(context) && success; @@ -275,7 +423,9 @@ void ptp_lab_task(void* argument) { } } - vTaskDelay(pdMS_TO_TICKS(5)); + // A Pdelay RX notification wakes this task immediately; the timeout only + // bounds the cadence check for Announce/Sync and avoids a busy loop. + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(5)); } } @@ -298,8 +448,16 @@ void start_ptp_lab(const esp_eth_handle_t eth_handle) { if (g_ptp_started) { return; } + g_ptp_context = {}; g_ptp_context.eth_handle = eth_handle; + g_ptp_context.pdelay_queue = xQueueCreate( + kPdelayQueueDepth, + sizeof(PdelayRequestEvent)); + if (g_ptp_context.pdelay_queue == nullptr) { + ESP_LOGW(kTag, "Pdelay queue allocation failed; broadcaster will run without Pdelay RX responses"); + } + g_ptp_started = true; if (xTaskCreatePinnedToCore( &ptp_lab_task, @@ -307,9 +465,13 @@ void start_ptp_lab(const esp_eth_handle_t eth_handle) { 6144, &g_ptp_context, 4, - nullptr, + &g_ptp_context.task_handle, 0) != pdPASS) { g_ptp_started = false; + if (g_ptp_context.pdelay_queue != nullptr) { + vQueueDelete(g_ptp_context.pdelay_queue); + g_ptp_context.pdelay_queue = nullptr; + } ESP_LOGE(kTag, "Failed to create PTP lab task on CPU0"); } } From a9f76fd71a177bf58bff54a9d3887b404375c150 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:39:02 +0700 Subject: [PATCH 26/39] test(ptp): lock Pdelay response wire identity and timestamps --- tests/test_ptp.cpp | 60 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/test_ptp.cpp b/tests/test_ptp.cpp index e3dd8f3ed..ce818ae45 100644 --- a/tests/test_ptp.cpp +++ b/tests/test_ptp.cpp @@ -90,6 +90,65 @@ void announce_matches_csharp_oracle_and_roundtrips() { CHECK(decoded.announce->grandmaster_identity == options.source_port_identity.clock_identity); } +void pdelay_response_preserves_request_identity_and_hardware_timestamp_fields() { + using namespace ar::iec61850::time_sync; + auto options = make_options(); + options.sequence_id = 0x0055U; + options.log_message_interval = -3; + options.two_step = true; + options.timestamp = {0x000102030405ULL, 222333444U}; + + const PtpPortIdentity requesting_port{ + {0x10U, 0x20U, 0x30U, 0x40U, 0x50U, 0x60U, 0x70U, 0x80U}, + 2U, + }; + + const auto response = PtpCodec::build_pdelay_resp(options, requesting_port); + CHECK(response.size() == 54U); + + PtpFrame decoded; + CHECK(PtpCodec::try_parse_message(response, decoded)); + CHECK(decoded.header.message_type == PtpMessageType::pdelay_resp); + CHECK(decoded.header.sequence_id == 0x0055U); + CHECK(decoded.header.log_message_interval == -3); + CHECK(decoded.header.is_two_step()); + CHECK(decoded.body.size() == 20U); + + PtpTimestamp request_receipt_timestamp; + CHECK(PtpTimestamp::try_read( + std::span{decoded.body}.first(10U), + request_receipt_timestamp)); + CHECK(request_receipt_timestamp == options.timestamp); + CHECK(std::equal( + requesting_port.clock_identity.begin(), + requesting_port.clock_identity.end(), + decoded.body.begin() + 10)); + CHECK(decoded.body[18] == 0x00U); + CHECK(decoded.body[19] == 0x02U); + + auto follow_up_options = options; + follow_up_options.two_step = false; + follow_up_options.timestamp = {0x000102030406ULL, 555666777U}; + const auto follow_up = PtpCodec::build_pdelay_resp_follow_up( + follow_up_options, + requesting_port); + CHECK(follow_up.size() == 54U); + CHECK(PtpCodec::try_parse_message(follow_up, decoded)); + CHECK(decoded.header.message_type == PtpMessageType::pdelay_resp_follow_up); + CHECK(decoded.header.sequence_id == 0x0055U); + CHECK(!decoded.header.is_two_step()); + + PtpTimestamp response_origin_timestamp; + CHECK(PtpTimestamp::try_read( + std::span{decoded.body}.first(10U), + response_origin_timestamp)); + CHECK(response_origin_timestamp == follow_up_options.timestamp); + CHECK(std::equal( + requesting_port.clock_identity.begin(), + requesting_port.clock_identity.end(), + decoded.body.begin() + 10)); +} + void ethernet_vlan_and_qinq_are_parsed_for_analyzer_use() { using namespace ar::iec61850::time_sync; auto options = make_options(); @@ -184,6 +243,7 @@ int main() { const std::vector>> tests{ {"PTP Sync C# oracle", sync_matches_csharp_oracle_and_roundtrips}, {"PTP Announce C# oracle", announce_matches_csharp_oracle_and_roundtrips}, + {"PTP Pdelay response wire fields", pdelay_response_preserves_request_identity_and_hardware_timestamp_fields}, {"PTP VLAN/QinQ analyzer parser", ethernet_vlan_and_qinq_are_parsed_for_analyzer_use}, {"PTP monitor health and smpSynch", passive_monitor_health_drives_conservative_smp_synch_policy}, {"PTP malformed input", malformed_or_non_ptp_frames_are_rejected}, From 1dea14df526b913fb91dcd8d7eb5001a0805c7fe Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:42:32 +0700 Subject: [PATCH 27/39] docs(ptp): define lab timing companion architecture and bench acceptance --- docs/PTP_LAB_TIMING.md | 290 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 docs/PTP_LAB_TIMING.md diff --git a/docs/PTP_LAB_TIMING.md b/docs/PTP_LAB_TIMING.md new file mode 100644 index 000000000..b203e3f9b --- /dev/null +++ b/docs/PTP_LAB_TIMING.md @@ -0,0 +1,290 @@ +# PTP Lab Timing Companion + +This document defines the PTP role in ARStack61850 and the evidence boundary for using it with the ESP32-P4 Sampled Values Injector and future analyzer products. + +## Product role + +ARStack PTP is a **laboratory and field-troubleshooting timing companion**. + +A common process-bus commissioning problem is: + +```text +relay / subscriber requires PTP before it will accept or use Sampled Values + | + v + station GPS/PTP server + not installed yet, + unavailable, or suspect + | + v + SMV injection cannot proceed +``` + +The ARStack ESP32-P4 path is intended to close that temporary test gap: + +```text + ARStack ESP32-P4 + / \ + / \ + Sampled Values PTPv2 L2 + Injector timing companion + \ / + \ / + protection relay +``` + +This makes the instrument useful during troubleshooting, internal testing and interoperability work without pretending to be the station timing system. + +It is **not** a replacement for a GPS-backed time server and it is not a certified grandmaster. + +## Shared Injector / Analyzer architecture + +The PTP wire model belongs in the portable C++ core rather than in the ESP32 firmware or a desktop GUI. + +```text + portable ARStack C++ + + PTP codec / parser / model + | + +-----------+-----------+ + | | + v v + passive monitor message builder + health / evidence Announce / Sync / + PCAP analyzer Follow_Up / Pdelay + | | + v v + future PTP/SMV ESP32-P4 PTP lab + Analyzer timing companion +``` + +The same parser and semantic model therefore serve both product directions: + +- **Injector** — transmit PTP traffic beside deterministic SMV traffic; +- **Analyzer** — observe PTP source/domain/message behavior without transmitting anything. + +## PTP-P1 implementation boundary + +PTP-P1 provides the laboratory transport/timestamp foundation. + +### Portable C++ core + +Implemented: + +- IEEE 1588/PTPv2 Layer-2 EtherType `0x88F7`; +- PTP header encode/decode; +- Announce; +- Sync; +- Follow_Up; +- Pdelay_Req encoding; +- Pdelay_Resp; +- Pdelay_Resp_Follow_Up; +- VLAN parsing; +- QinQ parsing; +- general and peer-delay multicast recognition; +- passive source/domain/message monitor; +- sequence-anomaly tracking; +- conservative health and `smpSynch` policy primitives; +- read-only PCAP analyzer CLI. + +### ESP32-P4 hardware timing path + +When `CONFIG_AR_PTP_LAB_TX=y` on the current ESP-IDF 5.x adapter: + +1. the ESP32-P4 IEEE1588 hardware clock is enabled; +2. Announce is emitted at the configured interval; +3. two-step Sync is transmitted; +4. the actual EMAC TX descriptor timestamp of Sync becomes the Follow_Up precise-origin timestamp; +5. incoming Pdelay_Req frames are received through the timestamp-aware Ethernet input callback; +6. the actual EMAC RX descriptor timestamp becomes the Pdelay_Resp request-receipt timestamp (`t2`); +7. the actual EMAC TX descriptor timestamp of Pdelay_Resp becomes the Pdelay_Resp_Follow_Up response-origin timestamp (`t3`). + +The PTP task runs separately from the Sampled Values realtime task. It does not move PTP parsing, queue handling or response construction into the SMV hot loop. + +## Configuration + +PTP transmission is **off by default**. + +Relevant ESP32-P4 configuration options: + +```text +CONFIG_AR_PTP_LAB_TX +CONFIG_AR_PTP_DOMAIN +CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS +CONFIG_AR_PTP_SYNC_INTERVAL_MS +``` + +Current defaults when the feature is enabled: + +```text +Domain 0 +Announce interval 1000 ms +Sync interval 250 ms +Two-step clock yes +Clock class 248 +Clock accuracy unknown +Variance 0xFFFF +Time source internal oscillator +Priority1 128 +Priority2 128 +``` + +These clock-quality values are intentionally conservative. The lab helper must not advertise GPS traceability or calibrated accuracy it does not possess. + +## `smpSynch` safety rule + +PTP traffic visibility and Sampled Values synchronization claims are different facts. + +```text +PTP packets transmitted + != +measured synchronized Sampled Values clock +``` + +PTP-P1 therefore does **not** automatically promote canonical SMV `smpSynch` merely because the local PTP broadcaster is enabled. + +The portable ASDU default is fail-safe: + +```text +smpSynch = 0 +``` + +A future automatic `smpSynch` transition requires measured clock state and an explicit profile rule. + +Compatibility overrides may later be exposed for controlled laboratory use, but they must remain visibly distinct from measured synchronization. + +## Read-only PTP Analyzer CLI + +The portable stack includes: + +```text +ariec61850_ptp_analyze [--domain N] [--json] +``` + +It reports, among other evidence: + +- PTP versus non-PTP frame counts; +- observed domain; +- source port identity; +- Announce count; +- Sync count; +- Follow_Up count; +- Pdelay activity; +- VLAN/QinQ visibility; +- sequence anomalies; +- basic liveness/health checks. + +Host PCAP timestamps are observation timestamps, not proof of PTP clock accuracy. The analyzer deliberately does not convert a normal host capture into a precision-timing claim. + +## Current RX ownership note + +The ESP32-P4 injector is currently a standalone raw Layer-2 application with no competing TCP/IP input consumer. PTP-P1 registers the timestamp-aware Ethernet RX callback and owns/frees the received buffers it inspects. + +A future combined PTP/SMV analyzer or application with multiple RX consumers should introduce one shared raw-Ethernet dispatcher rather than registering competing Ethernet input paths. + +## Bench acceptance — PTP-P1 + +Software/CI success is not physical relay acceptance. The first hardware interoperability run should retain packet and device evidence for the following. + +### A. Broadcaster visibility + +On an independent capture path verify: + +- EtherType `0x88F7`; +- expected source MAC / clock identity; +- configured PTP domain; +- periodic Announce; +- periodic Sync; +- matching Follow_Up sequence IDs; +- two-step flag on Sync; +- non-zero, progressing Follow_Up hardware timestamps after the hardware clock is initialized. + +### B. Peer-delay exchange + +When the relay emits Pdelay_Req verify: + +```text +Relay ARStack ESP32-P4 + | | + |---- Pdelay_Req ------>| RX descriptor timestamp = t2 + | | + |<--- Pdelay_Resp ------| body carries t2 + | | TX descriptor timestamp = t3 + |<-- Pdelay_Resp_FU ----| body carries t3 +``` + +Acceptance evidence should show: + +- response sequence ID equals request sequence ID; +- requestingPortIdentity is echoed exactly; +- response and follow-up use the peer-delay multicast destination; +- timestamps are hardware-derived and valid; +- no sustained response loss under the relay's normal Pdelay cadence. + +### C. Relay behavior + +Record, without overclaiming: + +- whether the relay detects the ARStack timing source; +- whether it selects/locks to it in the isolated test setup; +- time until relay timing-ready state; +- whether SMV becomes readable/usable after PTP is available; +- any relay-specific profile mismatch or rejection diagnostic. + +A successful result proves interoperability with that tested relay/configuration. It is not a universal IEC/IEEE 61850-9-3 conformance certificate. + +### D. SMV non-regression + +With PTP and SMV active simultaneously retain the existing SMV telemetry and verify: + +- requested publisher rate remains stable; +- no new canonical TX failure trend; +- no sustained missed-slot increase; +- canonical SMV identity and VLAN/APPID remain unchanged; +- PTP processing stays outside the realtime SMV hot path. + +## Suggested capture filters + +Useful Wireshark display filters for the bench are: + +```text +ptp +eth.type == 0x88f7 +eth.type == 0x88ba +ptp || eth.type == 0x88ba +``` + +For evidence, keep the unfiltered raw capture whenever possible and use filters only for inspection. + +## PTP-P2 direction + +PTP-P1 intentionally stops before claiming disciplined synchronization. The next timing phase should add: + +1. explicit lab epoch/time seeding rather than assuming ESP system UTC is valid; +2. hardware timestamp telemetry/correlation; +3. external PTP observation path; +4. applicable power-profile validation; +5. peer-delay / offset calculations where ARStack operates as a synchronized client/source; +6. hardware-clock phase/frequency adjustment; +7. lock / holdover / unlocked state machine; +8. measured-state-driven `smpSynch` policy; +9. retained offset/jitter evidence. + +The relevant ESP-IDF 5.x hardware adapter already exposes hardware-clock time and phase/frequency adjustment commands, but those controls should only be used once the servo and evidence model are explicitly designed and tested. + +## Claim boundary + +PTP-P1 may be described as: + +> ESP32-P4 hardware-timestamped PTPv2 Layer-2 laboratory timing companion for Sampled Values troubleshooting and interoperability testing. + +PTP-P1 must **not** be described as: + +- GPS grandmaster replacement; +- GPS-traceable clock; +- calibrated precision time source; +- certified IEC/IEEE 61850-9-3 implementation; +- universal relay-compatible grandmaster; +- evidence that SMV `smpSynch=2` is automatically justified. + +The product value is practical: make process-bus testing possible when normal station timing infrastructure is temporarily unavailable, while keeping every synchronization claim honest and evidence-based. From b35df9de9bcbfc70450c9e741317b275238b4fd9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:43:23 +0700 Subject: [PATCH 28/39] test(ptp): add standalone cross-platform PTP build profile --- tests/ptp_profile/CMakeLists.txt | 38 ++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 tests/ptp_profile/CMakeLists.txt diff --git a/tests/ptp_profile/CMakeLists.txt b/tests/ptp_profile/CMakeLists.txt new file mode 100644 index 000000000..36c0e4391 --- /dev/null +++ b/tests/ptp_profile/CMakeLists.txt @@ -0,0 +1,38 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARStack61850PtpProfile LANGUAGES CXX) + +get_filename_component(ARSTACK_ROOT "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE) + +add_library(ptp_profile_core STATIC + "${ARSTACK_ROOT}/src/ethernet/ethernet.cpp" + "${ARSTACK_ROOT}/src/time_sync/ptp.cpp" + "${ARSTACK_ROOT}/src/time_sync/ptp_monitor.cpp" + "${ARSTACK_ROOT}/src/capture/pcap.cpp") + +target_include_directories(ptp_profile_core PUBLIC + "${ARSTACK_ROOT}/include") +target_compile_features(ptp_profile_core PUBLIC cxx_std_20) + +add_executable(ptp_profile_tests + "${ARSTACK_ROOT}/tests/test_ptp.cpp") +target_link_libraries(ptp_profile_tests PRIVATE ptp_profile_core) +target_compile_features(ptp_profile_tests PRIVATE cxx_std_20) + +add_executable(ptp_profile_analyze + "${ARSTACK_ROOT}/tools/ptp_analyze.cpp") +target_link_libraries(ptp_profile_analyze PRIVATE ptp_profile_core) +target_compile_features(ptp_profile_analyze PRIVATE cxx_std_20) + +foreach(target IN ITEMS ptp_profile_core ptp_profile_tests ptp_profile_analyze) + if(MSVC) + target_compile_options(${target} PRIVATE /W4 /WX /permissive-) + else() + target_compile_options(${target} PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) + endif() +endforeach() + +enable_testing() +add_test(NAME ptp_profile_tests COMMAND ptp_profile_tests) +add_test(NAME ptp_profile_analyze_help COMMAND ptp_profile_analyze --help) From 31da89afa501b27ff854878b54671302146b7fe0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:43:52 +0700 Subject: [PATCH 29/39] ci(ptp): isolate strict MSVC analyzer build profile --- .github/workflows/ptp-core-ci.yml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ptp-core-ci.yml b/.github/workflows/ptp-core-ci.yml index 53871f0cd..531da7a6d 100644 --- a/.github/workflows/ptp-core-ci.yml +++ b/.github/workflows/ptp-core-ci.yml @@ -7,7 +7,10 @@ on: - 'src/time_sync/**' - 'include/ariec61850/ethernet/**' - 'src/ethernet/**' + - 'src/capture/pcap.cpp' + - 'tools/ptp_analyze.cpp' - 'tests/test_ptp.cpp' + - 'tests/ptp_profile/**' - '.github/workflows/ptp-core-ci.yml' pull_request: paths: @@ -15,7 +18,10 @@ on: - 'src/time_sync/**' - 'include/ariec61850/ethernet/**' - 'src/ethernet/**' + - 'src/capture/pcap.cpp' + - 'tools/ptp_analyze.cpp' - 'tests/test_ptp.cpp' + - 'tests/ptp_profile/**' - '.github/workflows/ptp-core-ci.yml' permissions: @@ -91,11 +97,11 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Configure strict MSVC build + - name: Configure standalone strict MSVC PTP profile shell: pwsh - run: cmake -S . -B build-ptp-msvc -A x64 -DARIEC61850_BUILD_TESTS=ON -DARIEC61850_BUILD_TOOLS=ON -DARIEC61850_WARNINGS_AS_ERRORS=ON + run: cmake -S tests/ptp_profile -B build-ptp-msvc -A x64 - - name: Build with diagnostics + - name: Build PTP core and analyzer with diagnostics shell: pwsh run: | $output = & cmake --build build-ptp-msvc --config Release --parallel 2>&1 @@ -109,6 +115,6 @@ jobs: exit $status } - - name: Run PTP regression test on MSVC + - name: Run PTP regression and analyzer smoke tests on MSVC shell: pwsh - run: ctest --test-dir build-ptp-msvc -C Release -R ariec61850_ptp_tests --output-on-failure + run: ctest --test-dir build-ptp-msvc -C Release --output-on-failure From 185052de58704bae9e51c019f3d0c056a1af1963 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:46:12 +0700 Subject: [PATCH 30/39] feat(ptp): expose transport-specific and VLAN lab bindings --- .../main/Kconfig.projbuild | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild index dfad84b27..80be5cd1a 100644 --- a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild +++ b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild @@ -53,8 +53,9 @@ config AR_PTP_LAB_TX Enables a low-rate PTPv2 Layer-2 laboratory broadcaster for isolated troubleshooting, internal testing and interoperability work when a normal station PTP/GPS time server is unavailable. On ESP32-P4 with - ESP-IDF 5.x the two-step Sync/Follow_Up path uses the EMAC hardware TX - timestamp. This feature is not a GPS-backed or certified grandmaster. + ESP-IDF 5.x the two-step Sync/Follow_Up and Pdelay response paths use + EMAC hardware timestamps. This feature is not a GPS-backed or certified + grandmaster. config AR_PTP_DOMAIN int "PTP domain number" @@ -62,7 +63,43 @@ config AR_PTP_DOMAIN default 0 depends on AR_PTP_LAB_TX help - Domain number written into Announce, Sync and Follow_Up messages. + Domain number written into Announce, Sync, Follow_Up and Pdelay + response messages. + +config AR_PTP_TRANSPORT_SPECIFIC + int "PTP transportSpecific nibble" + range 0 15 + default 0 + depends on AR_PTP_LAB_TX + help + Upper-nibble transportSpecific value written into PTP messageType. + Keep the default 0 for the ARIEC61850/default IEEE 1588 lab behavior. + Change it only when the tested relay/profile requires another value; + strict PTP receivers may reject a transportSpecific mismatch. + +config AR_PTP_VLAN + bool "Tag PTP lab traffic with IEEE 802.1Q VLAN" + default n + depends on AR_PTP_LAB_TX + help + Tags Announce, Sync/Follow_Up and Pdelay responses. Leave disabled for + the default untagged C#-oracle behavior. Enable only when the isolated + process-bus test network or target relay expects tagged PTP traffic. + +config AR_PTP_VLAN_ID + int "PTP VLAN ID" + range 0 4095 + default 0 + depends on AR_PTP_LAB_TX && AR_PTP_VLAN + +config AR_PTP_VLAN_PRIORITY + int "PTP VLAN PCP" + range 0 7 + default 4 + depends on AR_PTP_LAB_TX && AR_PTP_VLAN + help + IEEE 802.1Q priority code point for tagged PTP lab traffic. PCP 4 keeps + parity with the existing ARIEC61850 PTP publisher option default. config AR_PTP_ANNOUNCE_INTERVAL_MS int "PTP Announce interval (ms)" From 58f4509efc644f799665b35fedee1b6d4e578234 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:47:49 +0700 Subject: [PATCH 31/39] feat(ptp): bind transport-specific and optional VLAN to ESP32 traffic --- .../main/ptp_lab_task.cpp | 62 ++++++++++++++----- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index cb789d4be..658028333 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -25,6 +25,8 @@ #include #include #include +#include +#include #include namespace ar::esp32p4::smv { @@ -96,6 +98,22 @@ bool g_ptp_started = false; }; } +[[nodiscard]] std::optional ptp_vlan_id() noexcept { +#if defined(CONFIG_AR_PTP_VLAN) && CONFIG_AR_PTP_VLAN + return static_cast(CONFIG_AR_PTP_VLAN_ID); +#else + return std::nullopt; +#endif +} + +[[nodiscard]] std::uint8_t ptp_vlan_priority() noexcept { +#if defined(CONFIG_AR_PTP_VLAN) && CONFIG_AR_PTP_VLAN + return static_cast(CONFIG_AR_PTP_VLAN_PRIORITY); +#else + return 0U; +#endif +} + #if defined(SOC_EMAC_IEEE1588V2_SUPPORTED) && SOC_EMAC_IEEE1588V2_SUPPORTED && ESP_IDF_VERSION_MAJOR < 6 [[nodiscard]] bool valid_hw_timestamp(const eth_mac_time_t& timestamp) noexcept { @@ -178,6 +196,18 @@ void seed_hardware_clock(const esp_eth_handle_t eth_handle) noexcept { return true; } +[[nodiscard]] std::vector wrap_ptp_message( + const std::array& destination_mac, + const PtpLabContext& context, + const std::vector& message) { + return PtpCodec::build_ethernet_frame( + destination_mac, + context.source_mac, + message, + ptp_vlan_id(), + ptp_vlan_priority()); +} + [[nodiscard]] bool send_sync_follow_up(PtpLabContext& context) noexcept { auto sync_options = context.base_options; sync_options.sequence_id = context.sync_sequence; @@ -189,10 +219,7 @@ void seed_hardware_clock(const esp_eth_handle_t eth_handle) noexcept { sync_options.timestamp = {}; const auto sync_message = PtpCodec::build_sync(sync_options); - auto sync_frame = PtpCodec::build_ethernet_frame( - ptp_general_multicast_mac, - context.source_mac, - sync_message); + auto sync_frame = wrap_ptp_message(ptp_general_multicast_mac, context, sync_message); PtpTimestamp tx_timestamp; if (!transmit_with_hw_timestamp(context.eth_handle, sync_frame, tx_timestamp)) { @@ -203,9 +230,9 @@ void seed_hardware_clock(const esp_eth_handle_t eth_handle) noexcept { follow_up_options.two_step = false; follow_up_options.timestamp = tx_timestamp; const auto follow_up_message = PtpCodec::build_follow_up(follow_up_options); - auto follow_up_frame = PtpCodec::build_ethernet_frame( + auto follow_up_frame = wrap_ptp_message( ptp_general_multicast_mac, - context.source_mac, + context, follow_up_message); if (!send_frame(context.eth_handle, follow_up_frame)) { return false; @@ -223,10 +250,7 @@ void seed_hardware_clock(const esp_eth_handle_t eth_handle) noexcept { options.timestamp = hardware_time(context.eth_handle); const auto message = PtpCodec::build_announce(options, kCurrentUtcOffset); - auto frame = PtpCodec::build_ethernet_frame( - ptp_general_multicast_mac, - context.source_mac, - message); + auto frame = wrap_ptp_message(ptp_general_multicast_mac, context, message); if (!send_frame(context.eth_handle, frame)) { return false; } @@ -247,9 +271,9 @@ void seed_hardware_clock(const esp_eth_handle_t eth_handle) noexcept { const auto response_message = PtpCodec::build_pdelay_resp( response_options, request.requesting_port_identity); - auto response_frame = PtpCodec::build_ethernet_frame( + auto response_frame = wrap_ptp_message( ptp_peer_delay_multicast_mac, - context.source_mac, + context, response_message); PtpTimestamp response_tx_timestamp; @@ -267,9 +291,9 @@ void seed_hardware_clock(const esp_eth_handle_t eth_handle) noexcept { const auto follow_up_message = PtpCodec::build_pdelay_resp_follow_up( follow_up_options, request.requesting_port_identity); - auto follow_up_frame = PtpCodec::build_ethernet_frame( + auto follow_up_frame = wrap_ptp_message( ptp_peer_delay_multicast_mac, - context.source_mac, + context, follow_up_message); if (!send_frame(context.eth_handle, follow_up_frame)) { return false; @@ -298,8 +322,10 @@ esp_err_t ptp_input_info( if (PtpCodec::try_parse_ethernet_frame( std::span{buffer, length}, frame) && + frame.peer_delay_multicast && frame.header.message_type == PtpMessageType::pdelay_req && - frame.header.domain_number == context->base_options.domain_number) { + frame.header.domain_number == context->base_options.domain_number && + frame.header.transport_specific == context->base_options.transport_specific) { const auto& rx_timestamp = *static_cast(info); if (valid_hw_timestamp(rx_timestamp)) { const PdelayRequestEvent event{ @@ -347,6 +373,8 @@ void ptp_lab_task(void* argument) { return; } + context.base_options.transport_specific = + static_cast(CONFIG_AR_PTP_TRANSPORT_SPECIFIC); context.base_options.domain_number = static_cast(CONFIG_AR_PTP_DOMAIN); context.base_options.source_port_identity.clock_identity = clock_identity_from_mac(context.source_mac); @@ -377,8 +405,10 @@ void ptp_lab_task(void* argument) { ESP_LOGW(kTag, "PTP LAB TX enabled: troubleshooting/interoperability helper only; not a GPS-backed or certified grandmaster"); ESP_LOGI(kTag, - "PTP domain=%u Announce=%d ms Sync=%d ms HW Sync TX timestamp=enabled HW Pdelay=%s", + "PTP domain=%u transportSpecific=0x%X VLAN=%s Announce=%d ms Sync=%d ms HW Sync TX timestamp=enabled HW Pdelay=%s", static_cast(context.base_options.domain_number), + static_cast(context.base_options.transport_specific), + ptp_vlan_id().has_value() ? "tagged" : "untagged", CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS, CONFIG_AR_PTP_SYNC_INTERVAL_MS, pdelay_enabled ? "enabled" : "disabled"); From b569b26aa5225a26f51c9be06b27f004ac1c5aba Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:48:35 +0700 Subject: [PATCH 32/39] feat(ptp): expose transport-specific evidence in passive monitor --- include/ariec61850/time_sync/ptp_monitor.hpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/include/ariec61850/time_sync/ptp_monitor.hpp b/include/ariec61850/time_sync/ptp_monitor.hpp index 368c5b2ac..b53f86be0 100644 --- a/include/ariec61850/time_sync/ptp_monitor.hpp +++ b/include/ariec61850/time_sync/ptp_monitor.hpp @@ -19,6 +19,7 @@ namespace ar::iec61850::time_sync { struct PtpObservedMessage final { std::chrono::system_clock::time_point observed_at{}; PtpMessageType message_type{PtpMessageType::sync}; + std::uint8_t transport_specific{}; std::uint8_t domain_number{}; PtpPortIdentity source_port_identity{}; std::uint16_t sequence_id{}; @@ -36,6 +37,8 @@ struct PtpSourceClockSnapshot final { std::array message_counts{}; std::array, 16> last_sequence_ids{}; std::uint32_t sequence_anomaly_count{}; + std::optional transport_specific; + std::uint32_t transport_specific_change_count{}; std::optional vlan_id; std::optional outer_vlan_id; @@ -100,6 +103,7 @@ struct PtpHealthCheck final { struct PtpTimingHealthOptions final { std::optional expected_domain_number; + std::optional expected_transport_specific; std::chrono::milliseconds source_timeout{3000}; bool require_announce{true}; bool require_sync{true}; From 6b0ae32c4307abaa3bbcbb9e272d076765029b9a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:49:30 +0700 Subject: [PATCH 33/39] feat(ptp): validate and diagnose transport-specific evidence --- src/time_sync/ptp_monitor.cpp | 50 ++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/src/time_sync/ptp_monitor.cpp b/src/time_sync/ptp_monitor.cpp index 8fb93cfcd..203a10179 100644 --- a/src/time_sync/ptp_monitor.cpp +++ b/src/time_sync/ptp_monitor.cpp @@ -3,7 +3,7 @@ #include "ariec61850/time_sync/ptp_monitor.hpp" #include -#include +#include namespace ar::iec61850::time_sync { namespace { @@ -108,6 +108,7 @@ void PtpPassiveMonitor::observe( const PtpObservedMessage observed{ observed_at, frame.header.message_type, + frame.header.transport_specific, frame.header.domain_number, frame.header.source_port_identity, frame.header.sequence_id, @@ -131,12 +132,18 @@ void PtpPassiveMonitor::observe( state.snapshot.source_port_identity = frame.header.source_port_identity; state.snapshot.first_seen_at = observed_at; state.snapshot.last_seen_at = observed_at; + state.snapshot.transport_specific = frame.header.transport_specific; sources_.push_back(state); source = &sources_.back(); } auto& state = source->snapshot; state.last_seen_at = observed_at; + if (state.transport_specific.has_value() && + *state.transport_specific != frame.header.transport_specific) { + ++state.transport_specific_change_count; + } + state.transport_specific = frame.header.transport_specific; state.vlan_id = frame.vlan_id; state.outer_vlan_id = frame.outer_vlan_id; const auto index = message_index(frame.header.message_type); @@ -249,12 +256,38 @@ PtpTimingHealthReport PtpTimingHealthValidator::evaluate( } } + if (options.expected_transport_specific.has_value()) { + const auto matching = std::count_if(active_sources.begin(), active_sources.end(), [&](const auto* source) { + return source->transport_specific.has_value() && + *source->transport_specific == *options.expected_transport_specific; + }); + if (matching == 0) { + checks.push_back({ + "ptp.transportSpecific", + PtpHealthSeverity::fail, + "No active source is using expected transportSpecific " + + std::to_string(*options.expected_transport_specific) + ".", + }); + } else { + checks.push_back({ + "ptp.transportSpecific", + PtpHealthSeverity::ok, + "Expected transportSpecific is visible.", + }); + } + } + const PtpSourceClockSnapshot* selected = nullptr; for (const auto* source : active_sources) { if (options.expected_domain_number.has_value() && source->domain_number != *options.expected_domain_number) { continue; } + if (options.expected_transport_specific.has_value() && + (!source->transport_specific.has_value() || + *source->transport_specific != *options.expected_transport_specific)) { + continue; + } if (selected == nullptr || source->count(PtpMessageType::announce) > selected->count(PtpMessageType::announce) || (source->count(PtpMessageType::announce) == selected->count(PtpMessageType::announce) && @@ -272,6 +305,21 @@ PtpTimingHealthReport PtpTimingHealthValidator::evaluate( } report.selected_source = selected->source_port_identity; + if (selected->transport_specific_change_count != 0U) { + checks.push_back({ + "ptp.transportSpecific.stability", + PtpHealthSeverity::warning, + "Selected source changed transportSpecific " + + std::to_string(selected->transport_specific_change_count) + " time(s) during the capture.", + }); + } else { + checks.push_back({ + "ptp.transportSpecific.stability", + PtpHealthSeverity::ok, + "Selected source transportSpecific remained stable.", + }); + } + if (options.require_announce) { add_presence_check(checks, *selected, PtpMessageType::announce, "ptp.announce", "Announce"); } From 8c29613ed14da6b6e1a9fd28714dcdd139142610 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:50:18 +0700 Subject: [PATCH 34/39] feat(ptp): report and validate transport-specific in analyzer --- tools/ptp_analyze.cpp | 47 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/tools/ptp_analyze.cpp b/tools/ptp_analyze.cpp index 5916a30f1..5b2450b40 100644 --- a/tools/ptp_analyze.cpp +++ b/tools/ptp_analyze.cpp @@ -5,7 +5,6 @@ #include "ariec61850/time_sync/ptp_monitor.hpp" #include -#include #include #include #include @@ -41,21 +40,25 @@ using ar::iec61850::time_sync::PtpPortIdentity; return stream.str(); } -[[nodiscard]] std::uint8_t parse_domain(const std::string_view text) { +[[nodiscard]] std::uint8_t parse_byte_range( + const std::string_view text, + const unsigned maximum, + const char* label) { std::size_t consumed{}; const auto value = std::stoul(std::string{text}, &consumed, 0); - if (consumed != text.size() || value > 255UL) { - throw std::invalid_argument("PTP domain must be in the range 0..255."); + if (consumed != text.size() || value > maximum) { + throw std::invalid_argument( + std::string{label} + " must be in the range 0.." + std::to_string(maximum) + "."); } return static_cast(value); } void print_help() { std::cout - << "Usage: ariec61850_ptp_analyze [--domain N] [--json]\n" + << "Usage: ariec61850_ptp_analyze [--domain N] [--transport-specific N] [--json]\n" << "\n" << "Read-only PTPv2 Layer-2 analyzer for laboratory captures.\n" - << "It reports source/domain/message visibility and sequence health;\n" + << "It reports source/domain/transportSpecific/message visibility and sequence health;\n" << "it does not claim measured clock accuracy from host capture timestamps.\n"; } @@ -70,6 +73,7 @@ int main(int argc, char** argv) { std::filesystem::path capture_path; std::optional expected_domain; + std::optional expected_transport_specific; bool json = false; for (int index = 1; index < argc; ++index) { @@ -86,7 +90,17 @@ int main(int argc, char** argv) { if (index + 1 >= argc) { throw std::invalid_argument("--domain requires a value."); } - expected_domain = parse_domain(argv[++index]); + expected_domain = parse_byte_range(argv[++index], 255U, "PTP domain"); + continue; + } + if (argument == "--transport-specific") { + if (index + 1 >= argc) { + throw std::invalid_argument("--transport-specific requires a value."); + } + expected_transport_specific = parse_byte_range( + argv[++index], + 15U, + "transportSpecific"); continue; } if (!capture_path.empty()) { @@ -120,6 +134,7 @@ int main(int argc, char** argv) { const auto snapshot = monitor.snapshot(captured_at); ar::iec61850::time_sync::PtpTimingHealthOptions options; options.expected_domain_number = expected_domain; + options.expected_transport_specific = expected_transport_specific; const auto report = ar::iec61850::time_sync::PtpTimingHealthValidator::evaluate(snapshot, options); if (json) { @@ -141,7 +156,12 @@ int main(int argc, char** argv) { << "\"pdelayReq\":" << source.count(PtpMessageType::pdelay_req) << ',' << "\"pdelayResp\":" << source.count(PtpMessageType::pdelay_resp) << ',' << "\"pdelayRespFollowUp\":" << source.count(PtpMessageType::pdelay_resp_follow_up) << ',' - << "\"sequenceAnomalies\":" << source.sequence_anomaly_count; + << "\"sequenceAnomalies\":" << source.sequence_anomaly_count << ',' + << "\"transportSpecificChanges\":" << source.transport_specific_change_count; + if (source.transport_specific.has_value()) { + std::cout << ",\"transportSpecific\":" + << static_cast(*source.transport_specific); + } if (source.outer_vlan_id.has_value()) { std::cout << ",\"outerVlan\":" << *source.outer_vlan_id; } @@ -169,13 +189,22 @@ int main(int argc, char** argv) { << " health=" << severity_text(report.severity) << '\n'; for (const auto& source : snapshot.sources) { std::cout << " domain=" << static_cast(source.domain_number) - << " source=" << port_identity_text(source.source_port_identity) + << " transportSpecific="; + if (source.transport_specific.has_value()) { + std::cout << "0x" << std::hex + << static_cast(*source.transport_specific) + << std::dec; + } else { + std::cout << "unknown"; + } + std::cout << " source=" << port_identity_text(source.source_port_identity) << " announce=" << source.count(PtpMessageType::announce) << " sync=" << source.count(PtpMessageType::sync) << " followUp=" << source.count(PtpMessageType::follow_up) << " pdelayReq=" << source.count(PtpMessageType::pdelay_req) << " pdelayResp=" << source.count(PtpMessageType::pdelay_resp) << " seqAnomaly=" << source.sequence_anomaly_count + << " tsChanges=" << source.transport_specific_change_count << '\n'; } for (const auto& check : report.checks) { From bce64cb99091a1e3ead4fb568e95c11d4250c132 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:51:38 +0700 Subject: [PATCH 35/39] test(ptp): cover transport-specific analyzer evidence --- tests/test_ptp.cpp | 57 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/tests/test_ptp.cpp b/tests/test_ptp.cpp index ce818ae45..575a858f9 100644 --- a/tests/test_ptp.cpp +++ b/tests/test_ptp.cpp @@ -57,6 +57,7 @@ void sync_matches_csharp_oracle_and_roundtrips() { CHECK(decoded.header.message_type == PtpMessageType::sync); CHECK(decoded.header.version == 2U); CHECK(decoded.header.message_length == 44U); + CHECK(decoded.header.transport_specific == 0U); CHECK(decoded.header.domain_number == 0U); CHECK(decoded.header.is_two_step()); CHECK(decoded.header.sequence_id == 0x1234U); @@ -149,9 +150,10 @@ void pdelay_response_preserves_request_identity_and_hardware_timestamp_fields() decoded.body.begin() + 10)); } -void ethernet_vlan_and_qinq_are_parsed_for_analyzer_use() { +void ethernet_vlan_qinq_and_transport_specific_are_parsed_for_analyzer_use() { using namespace ar::iec61850::time_sync; auto options = make_options(); + options.transport_specific = 1U; const auto message = PtpCodec::build_sync(options); const std::array source{0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x01U}; const auto vlan_frame = PtpCodec::build_ethernet_frame( @@ -160,6 +162,7 @@ void ethernet_vlan_and_qinq_are_parsed_for_analyzer_use() { PtpFrame decoded; CHECK(PtpCodec::try_parse_ethernet_frame(vlan_frame, decoded)); + CHECK(decoded.header.transport_specific == 1U); CHECK(decoded.vlan_id == 100U); CHECK(!decoded.outer_vlan_id.has_value()); CHECK(!decoded.peer_delay_multicast); @@ -174,6 +177,7 @@ void ethernet_vlan_and_qinq_are_parsed_for_analyzer_use() { qinq.insert(qinq.end(), vlan_frame.begin() + 12, vlan_frame.end()); CHECK(PtpCodec::try_parse_ethernet_frame(qinq, decoded)); + CHECK(decoded.header.transport_specific == 1U); CHECK(decoded.outer_vlan_id == 10U); CHECK(decoded.vlan_id == 100U); CHECK(decoded.header.sequence_id == 0x1234U); @@ -205,8 +209,13 @@ void passive_monitor_health_drives_conservative_smp_synch_policy() { CHECK(monitor.observe_ethernet_frame(follow_up, observed_at + std::chrono::milliseconds{20})); const auto snapshot = monitor.snapshot(observed_at + std::chrono::milliseconds{100}); + CHECK(snapshot.sources.size() == 1U); + CHECK(snapshot.sources[0].transport_specific == 0U); + CHECK(snapshot.sources[0].transport_specific_change_count == 0U); + PtpTimingHealthOptions health_options; health_options.expected_domain_number = 0U; + health_options.expected_transport_specific = 0U; const auto report = PtpTimingHealthValidator::evaluate(snapshot, health_options); CHECK(report.is_healthy()); CHECK(report.selected_source.has_value()); @@ -222,6 +231,49 @@ void passive_monitor_health_drives_conservative_smp_synch_policy() { CHECK(resolve_smp_synch(incomplete_report, false) == SmpSynchValue::not_synchronized); } +void transport_specific_change_is_visible_as_health_warning() { + using namespace ar::iec61850::time_sync; + const auto observed_at = std::chrono::system_clock::time_point{std::chrono::seconds{2000}}; + const std::array source{0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x01U}; + + auto options = make_options(); + options.sequence_id = 1U; + options.two_step = false; + options.timestamp = {}; + const auto announce = PtpCodec::build_ethernet_frame( + ptp_general_multicast_mac, source, PtpCodec::build_announce(options)); + CHECK(!announce.empty()); + + options.two_step = true; + const auto sync = PtpCodec::build_ethernet_frame( + ptp_general_multicast_mac, source, PtpCodec::build_sync(options)); + CHECK(!sync.empty()); + + options.transport_specific = 1U; + options.two_step = false; + const auto follow_up_changed = PtpCodec::build_ethernet_frame( + ptp_general_multicast_mac, source, PtpCodec::build_follow_up(options)); + CHECK(!follow_up_changed.empty()); + + PtpPassiveMonitor monitor; + CHECK(monitor.observe_ethernet_frame(announce, observed_at)); + CHECK(monitor.observe_ethernet_frame(sync, observed_at + std::chrono::milliseconds{10})); + CHECK(monitor.observe_ethernet_frame( + follow_up_changed, + observed_at + std::chrono::milliseconds{20})); + + const auto snapshot = monitor.snapshot(observed_at + std::chrono::milliseconds{50}); + CHECK(snapshot.sources.size() == 1U); + CHECK(snapshot.sources[0].transport_specific == 1U); + CHECK(snapshot.sources[0].transport_specific_change_count == 1U); + + PtpTimingHealthOptions health_options; + health_options.expected_domain_number = 0U; + const auto report = PtpTimingHealthValidator::evaluate(snapshot, health_options); + CHECK(report.severity == PtpHealthSeverity::warning); + CHECK(resolve_smp_synch(report, true) == SmpSynchValue::local_synchronized); +} + void malformed_or_non_ptp_frames_are_rejected() { using namespace ar::iec61850::time_sync; PtpFrame frame; @@ -244,8 +296,9 @@ int main() { {"PTP Sync C# oracle", sync_matches_csharp_oracle_and_roundtrips}, {"PTP Announce C# oracle", announce_matches_csharp_oracle_and_roundtrips}, {"PTP Pdelay response wire fields", pdelay_response_preserves_request_identity_and_hardware_timestamp_fields}, - {"PTP VLAN/QinQ analyzer parser", ethernet_vlan_and_qinq_are_parsed_for_analyzer_use}, + {"PTP VLAN/QinQ/transportSpecific analyzer parser", ethernet_vlan_qinq_and_transport_specific_are_parsed_for_analyzer_use}, {"PTP monitor health and smpSynch", passive_monitor_health_drives_conservative_smp_synch_policy}, + {"PTP transportSpecific stability", transport_specific_change_is_visible_as_health_warning}, {"PTP malformed input", malformed_or_non_ptp_frames_are_rejected}, }; From 9797b9cc19586a952b5f5585e477eabb0ca3f9cb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:53:48 +0700 Subject: [PATCH 36/39] ci(ptp): preserve actionable compiler diagnostics --- .github/workflows/ptp-core-ci.yml | 38 +++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ptp-core-ci.yml b/.github/workflows/ptp-core-ci.yml index 531da7a6d..40aa795bf 100644 --- a/.github/workflows/ptp-core-ci.yml +++ b/.github/workflows/ptp-core-ci.yml @@ -64,12 +64,12 @@ jobs: cat ptp-build.log if [ "$status" -ne 0 ]; then - while IFS= read -r line; do + grep -Ei 'error:|fatal error:|undefined reference|collect2: error|ld: error' ptp-build.log | while IFS= read -r line; do escaped=${line//'%'/'%25'} escaped=${escaped//$'\r'/'%0D'} escaped=${escaped//$'\n'/'%0A'} echo "::error::$escaped" - done < ptp-build.log + done || true exit "$status" fi @@ -82,12 +82,12 @@ jobs: set -e cat ptp-test.log if [ "$status" -ne 0 ]; then - while IFS= read -r line; do + grep -E '\[FAIL\]|CHECK failed|error' ptp-test.log | while IFS= read -r line; do escaped=${line//'%'/'%25'} escaped=${escaped//$'\r'/'%0D'} escaped=${escaped//$'\n'/'%0A'} echo "::error::$escaped" - done < ptp-test.log + done || true exit "$status" fi @@ -104,17 +104,31 @@ jobs: - name: Build PTP core and analyzer with diagnostics shell: pwsh run: | - $output = & cmake --build build-ptp-msvc --config Release --parallel 2>&1 - $status = $LASTEXITCODE - $output | Tee-Object -FilePath ptp-msvc-build.log - if ($status -ne 0) { - foreach ($line in $output) { - $escaped = "$line".Replace('%','%25').Replace("`r",'%0D').Replace("`n",'%0A') - Write-Output "::error::$escaped" + function Build-Target([string]$Target) { + $output = & cmake --build build-ptp-msvc --config Release --target $Target --parallel 2>&1 + $status = $LASTEXITCODE + $output | Tee-Object -FilePath "ptp-msvc-$Target.log" + if ($status -ne 0) { + $matched = $false + foreach ($line in $output) { + $text = "$line" + if ($text -match '(?i)(error C\d+|fatal error|error LNK\d+|error MSB\d+|: error )') { + $matched = $true + $escaped = $text.Replace('%','%25').Replace("`r",'%0D').Replace("`n",'%0A') + Write-Output "::error title=MSVC $Target::$escaped" + } + } + if (-not $matched) { + Write-Output "::error title=MSVC $Target::Build failed; inspect ptp-msvc-$Target.log in the job output." + } + exit $status } - exit $status } + Build-Target 'ptp_profile_core' + Build-Target 'ptp_profile_tests' + Build-Target 'ptp_profile_analyze' + - name: Run PTP regression and analyzer smoke tests on MSVC shell: pwsh run: ctest --test-dir build-ptp-msvc -C Release --output-on-failure From 9baf0da6f74d5fd644f2ba5828839e4c77349a26 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:56:45 +0700 Subject: [PATCH 37/39] docs(ptp): document profile bindings and analyzer diagnostics --- docs/PTP_LAB_TIMING.md | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/PTP_LAB_TIMING.md b/docs/PTP_LAB_TIMING.md index b203e3f9b..eecf1ccf2 100644 --- a/docs/PTP_LAB_TIMING.md +++ b/docs/PTP_LAB_TIMING.md @@ -61,7 +61,7 @@ The PTP wire model belongs in the portable C++ core rather than in the ESP32 fir The same parser and semantic model therefore serve both product directions: - **Injector** — transmit PTP traffic beside deterministic SMV traffic; -- **Analyzer** — observe PTP source/domain/message behavior without transmitting anything. +- **Analyzer** — observe PTP source/domain/profile/message behavior without transmitting anything. ## PTP-P1 implementation boundary @@ -73,6 +73,7 @@ Implemented: - IEEE 1588/PTPv2 Layer-2 EtherType `0x88F7`; - PTP header encode/decode; +- transportSpecific preservation; - Announce; - Sync; - Follow_Up; @@ -83,6 +84,7 @@ Implemented: - QinQ parsing; - general and peer-delay multicast recognition; - passive source/domain/message monitor; +- transportSpecific stability/mismatch evidence; - sequence-anomaly tracking; - conservative health and `smpSynch` policy primitives; - read-only PCAP analyzer CLI. @@ -110,6 +112,10 @@ Relevant ESP32-P4 configuration options: ```text CONFIG_AR_PTP_LAB_TX CONFIG_AR_PTP_DOMAIN +CONFIG_AR_PTP_TRANSPORT_SPECIFIC +CONFIG_AR_PTP_VLAN +CONFIG_AR_PTP_VLAN_ID +CONFIG_AR_PTP_VLAN_PRIORITY CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS CONFIG_AR_PTP_SYNC_INTERVAL_MS ``` @@ -118,6 +124,10 @@ Current defaults when the feature is enabled: ```text Domain 0 +transportSpecific 0 +PTP VLAN disabled / untagged +PTP VLAN ID 0 when tagging is enabled +PTP VLAN PCP 4 when tagging is enabled Announce interval 1000 ms Sync interval 250 ms Two-step clock yes @@ -129,6 +139,8 @@ Priority1 128 Priority2 128 ``` +The default `transportSpecific=0` and untagged Ethernet behavior preserve the existing ARIEC61850/ARSVIN laboratory behavior. They are configuration, not universal relay-profile claims. Some strict PTP implementations reject a `transportSpecific` mismatch, and real process-bus networks may expect a particular VLAN binding, so the lab tool exposes both explicitly rather than hard-coding a guessed utility profile. + These clock-quality values are intentionally conservative. The lab helper must not advertise GPS traceability or calibrated accuracy it does not possess. ## `smpSynch` safety rule @@ -158,13 +170,14 @@ Compatibility overrides may later be exposed for controlled laboratory use, but The portable stack includes: ```text -ariec61850_ptp_analyze [--domain N] [--json] +ariec61850_ptp_analyze [--domain N] [--transport-specific N] [--json] ``` It reports, among other evidence: - PTP versus non-PTP frame counts; - observed domain; +- observed `transportSpecific` and changes during the capture; - source port identity; - Announce count; - Sync count; @@ -172,7 +185,10 @@ It reports, among other evidence: - Pdelay activity; - VLAN/QinQ visibility; - sequence anomalies; -- basic liveness/health checks. +- basic liveness/health checks; +- expected-domain or expected-transportSpecific mismatch when requested. + +This is intended to help diagnose cases where PTP is visibly present on the wire but the target relay still ignores it because the profile identity or network binding does not match what that relay expects. Host PCAP timestamps are observation timestamps, not proof of PTP clock accuracy. The analyzer deliberately does not convert a normal host capture into a precision-timing claim. @@ -193,6 +209,8 @@ On an independent capture path verify: - EtherType `0x88F7`; - expected source MAC / clock identity; - configured PTP domain; +- configured `transportSpecific`; +- configured tagged/untagged state and VLAN/PCP when enabled; - periodic Announce; - periodic Sync; - matching Follow_Up sequence IDs; @@ -215,9 +233,11 @@ Relay ARStack ESP32-P4 Acceptance evidence should show: +- incoming Pdelay_Req uses the expected domain and `transportSpecific`; - response sequence ID equals request sequence ID; - requestingPortIdentity is echoed exactly; - response and follow-up use the peer-delay multicast destination; +- configured PTP VLAN behavior is preserved on the responses; - timestamps are hardware-derived and valid; - no sustained response loss under the relay's normal Pdelay cadence. @@ -229,7 +249,7 @@ Record, without overclaiming: - whether it selects/locks to it in the isolated test setup; - time until relay timing-ready state; - whether SMV becomes readable/usable after PTP is available; -- any relay-specific profile mismatch or rejection diagnostic. +- any relay-specific domain, transportSpecific, VLAN, timing-profile or clock-quality rejection diagnostic. A successful result proves interoperability with that tested relay/configuration. It is not a universal IEC/IEEE 61850-9-3 conformance certificate. From 081f4e5bd9c461dde1f76d0c62aff26fb68e09cc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 18:58:43 +0700 Subject: [PATCH 38/39] test(ptp): make wire-width expectations explicit for MSVC --- tests/test_ptp.cpp | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/tests/test_ptp.cpp b/tests/test_ptp.cpp index 575a858f9..fb6ea0a14 100644 --- a/tests/test_ptp.cpp +++ b/tests/test_ptp.cpp @@ -157,13 +157,18 @@ void ethernet_vlan_qinq_and_transport_specific_are_parsed_for_analyzer_use() { const auto message = PtpCodec::build_sync(options); const std::array source{0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x01U}; const auto vlan_frame = PtpCodec::build_ethernet_frame( - ptp_general_multicast_mac, source, message, 100U, 4U); + ptp_general_multicast_mac, + source, + message, + std::uint16_t{100}, + std::uint8_t{4}); CHECK(!vlan_frame.empty()); PtpFrame decoded; CHECK(PtpCodec::try_parse_ethernet_frame(vlan_frame, decoded)); CHECK(decoded.header.transport_specific == 1U); - CHECK(decoded.vlan_id == 100U); + CHECK(decoded.vlan_id.has_value()); + CHECK(*decoded.vlan_id == std::uint16_t{100}); CHECK(!decoded.outer_vlan_id.has_value()); CHECK(!decoded.peer_delay_multicast); @@ -178,8 +183,10 @@ void ethernet_vlan_qinq_and_transport_specific_are_parsed_for_analyzer_use() { CHECK(PtpCodec::try_parse_ethernet_frame(qinq, decoded)); CHECK(decoded.header.transport_specific == 1U); - CHECK(decoded.outer_vlan_id == 10U); - CHECK(decoded.vlan_id == 100U); + CHECK(decoded.outer_vlan_id.has_value()); + CHECK(*decoded.outer_vlan_id == std::uint16_t{10}); + CHECK(decoded.vlan_id.has_value()); + CHECK(*decoded.vlan_id == std::uint16_t{100}); CHECK(decoded.header.sequence_id == 0x1234U); } @@ -210,12 +217,13 @@ void passive_monitor_health_drives_conservative_smp_synch_policy() { const auto snapshot = monitor.snapshot(observed_at + std::chrono::milliseconds{100}); CHECK(snapshot.sources.size() == 1U); - CHECK(snapshot.sources[0].transport_specific == 0U); + CHECK(snapshot.sources[0].transport_specific.has_value()); + CHECK(*snapshot.sources[0].transport_specific == std::uint8_t{0}); CHECK(snapshot.sources[0].transport_specific_change_count == 0U); PtpTimingHealthOptions health_options; - health_options.expected_domain_number = 0U; - health_options.expected_transport_specific = 0U; + health_options.expected_domain_number = std::uint8_t{0}; + health_options.expected_transport_specific = std::uint8_t{0}; const auto report = PtpTimingHealthValidator::evaluate(snapshot, health_options); CHECK(report.is_healthy()); CHECK(report.selected_source.has_value()); @@ -264,11 +272,12 @@ void transport_specific_change_is_visible_as_health_warning() { const auto snapshot = monitor.snapshot(observed_at + std::chrono::milliseconds{50}); CHECK(snapshot.sources.size() == 1U); - CHECK(snapshot.sources[0].transport_specific == 1U); + CHECK(snapshot.sources[0].transport_specific.has_value()); + CHECK(*snapshot.sources[0].transport_specific == std::uint8_t{1}); CHECK(snapshot.sources[0].transport_specific_change_count == 1U); PtpTimingHealthOptions health_options; - health_options.expected_domain_number = 0U; + health_options.expected_domain_number = std::uint8_t{0}; const auto report = PtpTimingHealthValidator::evaluate(snapshot, health_options); CHECK(report.severity == PtpHealthSeverity::warning); CHECK(resolve_smp_synch(report, true) == SmpSynchValue::local_synchronized); From 5ec760427e7a4e00f6bce5bf3726420153f7be89 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 19:02:36 +0700 Subject: [PATCH 39/39] ci(ptp): compile non-default transport and VLAN profile --- .github/workflows/esp32p4-ptp-lab.yml | 29 +++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index 9b42df7a2..5cfa867da 100644 --- a/.github/workflows/esp32p4-ptp-lab.yml +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -36,7 +36,7 @@ jobs: - name: Select ESP32-P4 target run: source /opt/esp/idf/export.sh && idf.py set-target esp32p4 - - name: Enable guarded PTP lab profile + - name: Enable guarded non-default PTP lab profile shell: bash run: | if grep -q '^# CONFIG_AR_PTP_LAB_TX is not set$' sdkconfig; then @@ -44,9 +44,34 @@ jobs: elif ! grep -q '^CONFIG_AR_PTP_LAB_TX=y$' sdkconfig; then echo 'CONFIG_AR_PTP_LAB_TX=y' >> sdkconfig fi + + if grep -q '^CONFIG_AR_PTP_TRANSPORT_SPECIFIC=' sdkconfig; then + sed -i 's/^CONFIG_AR_PTP_TRANSPORT_SPECIFIC=.*/CONFIG_AR_PTP_TRANSPORT_SPECIFIC=1/' sdkconfig + else + echo 'CONFIG_AR_PTP_TRANSPORT_SPECIFIC=1' >> sdkconfig + fi + + if grep -q '^# CONFIG_AR_PTP_VLAN is not set$' sdkconfig; then + sed -i 's/^# CONFIG_AR_PTP_VLAN is not set$/CONFIG_AR_PTP_VLAN=y/' sdkconfig + elif ! grep -q '^CONFIG_AR_PTP_VLAN=y$' sdkconfig; then + echo 'CONFIG_AR_PTP_VLAN=y' >> sdkconfig + fi + + if grep -q '^CONFIG_AR_PTP_VLAN_ID=' sdkconfig; then + sed -i 's/^CONFIG_AR_PTP_VLAN_ID=.*/CONFIG_AR_PTP_VLAN_ID=100/' sdkconfig + else + echo 'CONFIG_AR_PTP_VLAN_ID=100' >> sdkconfig + fi + + if grep -q '^CONFIG_AR_PTP_VLAN_PRIORITY=' sdkconfig; then + sed -i 's/^CONFIG_AR_PTP_VLAN_PRIORITY=.*/CONFIG_AR_PTP_VLAN_PRIORITY=4/' sdkconfig + else + echo 'CONFIG_AR_PTP_VLAN_PRIORITY=4' >> sdkconfig + fi + source /opt/esp/idf/export.sh idf.py reconfigure - grep -E '^CONFIG_AR_PTP_(LAB_TX|DOMAIN|ANNOUNCE_INTERVAL_MS|SYNC_INTERVAL_MS)=' sdkconfig + grep -E '^CONFIG_AR_PTP_(LAB_TX|DOMAIN|TRANSPORT_SPECIFIC|VLAN|VLAN_ID|VLAN_PRIORITY|ANNOUNCE_INTERVAL_MS|SYNC_INTERVAL_MS)=' sdkconfig - name: Build PTP-enabled firmware run: source /opt/esp/idf/export.sh && idf.py build