From 46625385d87d64b46bc034c07df6f12adca0ca8b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 20:15:07 +0700 Subject: [PATCH 01/15] feat(ptp-sv): port M1/M2/M3 clock phase, source-local AUTO and guarded egress to current Studio baseline --- CMakeLists.txt | 7 + ...50_PTP_SV_SOFTWARE_COMPATIBILITY_MATRIX.md | 56 ++ docs/PTP_CANONICAL_RUNTIME_AUTHORITY_M3A.md | 32 + docs/PTP_EMAC_ROLE_OWNERSHIP_M3D1.md | 25 + docs/PTP_M3E_BENCH_EVIDENCE_GATE.md | 132 ++++ docs/PTP_RECEIVER_CLOCK_AUTHORITY_M3B.md | 23 + docs/PTP_SOURCE_FRESHNESS_M2F.md | 19 + docs/PTP_SOURCE_HEALTH_M2E.md | 26 + docs/PTP_START_STOP_CANCELLATION_M3D2.md | 46 ++ docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md | 72 ++ docs/PTP_SV_EPOCH_M1_ACCEPTANCE.md | 74 ++ docs/PTP_SV_HARDWARE_TX_EVIDENCE_M2C.md | 55 ++ docs/PTP_SV_PHASE_AUTHORITY_M2A.md | 55 ++ docs/PTP_SV_RECEIVER_EPOCH_BINDING_M3C2.md | 32 + docs/PTP_SV_WIRE_AUDIT_M2B.md | 38 + .../main/Kconfig.projbuild | 58 +- .../esp32p4_smv_injector/main/app_main.cpp | 696 +++++++++++++++++- .../main/ptp_lab_task.cpp | 202 ++++- .../main/ptp_lab_task.hpp | 22 + .../main/ptp_mode_facade.cpp | 209 ++++-- .../main/ptp_receiver_task.cpp | 47 +- .../main/ptp_receiver_task.hpp | 2 + .../main/smp_synch_lab.cpp | 27 +- .../main/smp_synch_lab.hpp | 6 + .../esp32p4_smv_injector/sdkconfig.defaults | 4 +- .../tools/stage_ptp_experimental.py | 93 +++ include/ariec61850/capture/pcap.hpp | 11 + include/ariec61850/time_sync/ptp.hpp | 19 + .../time_sync/ptp_canonical_authority.hpp | 60 ++ .../time_sync/ptp_control_cancellation.hpp | 72 ++ .../time_sync/ptp_emac_role_ownership.hpp | 22 + .../time_sync/ptp_power_profile.hpp | 110 +++ .../ptp_receiver_clock_authority.hpp | 68 ++ include/ariec61850/time_sync/ptp_runtime.hpp | 26 +- .../ariec61850/time_sync/ptp_tai_source.hpp | 31 + .../time_sync/sv_ptp_clock_lease.hpp | 52 ++ include/ariec61850/time_sync/sv_ptp_epoch.hpp | 102 +++ .../time_sync/sv_ptp_phase_authority.hpp | 318 ++++++++ .../time_sync/sv_tx_egress_evidence.hpp | 256 +++++++ src/capture/pcap.cpp | 26 +- src/time_sync/ptp.cpp | 3 +- tests/ptp_profile/CMakeLists.txt | 56 +- tests/test_ptp_canonical_authority.cpp | 67 ++ tests/test_ptp_control_cancellation.cpp | 64 ++ tests/test_ptp_emac_role_ownership.cpp | 23 + tests/test_ptp_m3e_bench_gate.py | 370 ++++++++++ tests/test_ptp_power_profile.cpp | 314 ++++++++ tests/test_ptp_receiver_clock_authority.cpp | 84 +++ tests/test_ptp_runtime.cpp | 44 ++ tests/test_sv_ptp_clock_lease.cpp | 74 ++ tests/test_sv_ptp_epoch.cpp | 79 ++ tests/test_sv_ptp_phase_authority.cpp | 371 ++++++++++ tests/test_sv_tx_egress_evidence.cpp | 200 +++++ tools/ptp_sv_wire_audit.cpp | 468 ++++++++++++ 54 files changed, 5359 insertions(+), 89 deletions(-) create mode 100644 docs/IEC61850_PTP_SV_SOFTWARE_COMPATIBILITY_MATRIX.md create mode 100644 docs/PTP_CANONICAL_RUNTIME_AUTHORITY_M3A.md create mode 100644 docs/PTP_EMAC_ROLE_OWNERSHIP_M3D1.md create mode 100644 docs/PTP_M3E_BENCH_EVIDENCE_GATE.md create mode 100644 docs/PTP_RECEIVER_CLOCK_AUTHORITY_M3B.md create mode 100644 docs/PTP_SOURCE_FRESHNESS_M2F.md create mode 100644 docs/PTP_SOURCE_HEALTH_M2E.md create mode 100644 docs/PTP_START_STOP_CANCELLATION_M3D2.md create mode 100644 docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md create mode 100644 docs/PTP_SV_EPOCH_M1_ACCEPTANCE.md create mode 100644 docs/PTP_SV_HARDWARE_TX_EVIDENCE_M2C.md create mode 100644 docs/PTP_SV_PHASE_AUTHORITY_M2A.md create mode 100644 docs/PTP_SV_RECEIVER_EPOCH_BINDING_M3C2.md create mode 100644 docs/PTP_SV_WIRE_AUDIT_M2B.md create mode 100644 embedded/esp32p4_smv_injector/tools/stage_ptp_experimental.py create mode 100644 include/ariec61850/time_sync/ptp_canonical_authority.hpp create mode 100644 include/ariec61850/time_sync/ptp_control_cancellation.hpp create mode 100644 include/ariec61850/time_sync/ptp_emac_role_ownership.hpp create mode 100644 include/ariec61850/time_sync/ptp_power_profile.hpp create mode 100644 include/ariec61850/time_sync/ptp_receiver_clock_authority.hpp create mode 100644 include/ariec61850/time_sync/ptp_tai_source.hpp create mode 100644 include/ariec61850/time_sync/sv_ptp_clock_lease.hpp create mode 100644 include/ariec61850/time_sync/sv_ptp_epoch.hpp create mode 100644 include/ariec61850/time_sync/sv_ptp_phase_authority.hpp create mode 100644 include/ariec61850/time_sync/sv_tx_egress_evidence.hpp create mode 100644 tests/test_ptp_canonical_authority.cpp create mode 100644 tests/test_ptp_control_cancellation.cpp create mode 100644 tests/test_ptp_emac_role_ownership.cpp create mode 100644 tests/test_ptp_m3e_bench_gate.py create mode 100644 tests/test_ptp_power_profile.cpp create mode 100644 tests/test_ptp_receiver_clock_authority.cpp create mode 100644 tests/test_sv_ptp_clock_lease.cpp create mode 100644 tests/test_sv_ptp_epoch.cpp create mode 100644 tests/test_sv_ptp_phase_authority.cpp create mode 100644 tests/test_sv_tx_egress_evidence.cpp create mode 100644 tools/ptp_sv_wire_audit.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index d4262e7d4..fcb550a5e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -166,6 +166,11 @@ if(ARIEC61850_BUILD_TOOLS) 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_ptp_sv_wire_audit tools/ptp_sv_wire_audit.cpp) + target_link_libraries(ariec61850_ptp_sv_wire_audit PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_ptp_sv_wire_audit PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_ptp_sv_wire_audit) + ariec61850_apply_sanitizers(ariec61850_ptp_sv_wire_audit) add_executable(ariec61850_comtrade_inspect tools/comtrade_inspect.cpp) target_link_libraries(ariec61850_comtrade_inspect PRIVATE ARIEC61850::core) @@ -498,6 +503,8 @@ if(ARIEC61850_BUILD_TESTS) endforeach() if(ARIEC61850_BUILD_TOOLS) + add_test(NAME ariec61850_ptp_sv_wire_audit_selftest + COMMAND ariec61850_ptp_sv_wire_audit --self-test) add_test( NAME ariec61850_pcap_interop_smoke COMMAND ariec61850_pcap_interop_check diff --git a/docs/IEC61850_PTP_SV_SOFTWARE_COMPATIBILITY_MATRIX.md b/docs/IEC61850_PTP_SV_SOFTWARE_COMPATIBILITY_MATRIX.md new file mode 100644 index 000000000..1cfba69b4 --- /dev/null +++ b/docs/IEC61850_PTP_SV_SOFTWARE_COMPATIBILITY_MATRIX.md @@ -0,0 +1,56 @@ +# IEC 61850 PTP + SV interoperability — software-only evidence matrix + +Status: **in progress, not a conformance certificate.** This matrix is designed for teams without a reference IED or timing analyzer. A passing host/ESP-IDF build demonstrates software consistency, not physical accuracy or relay synchronization. + +## Sources and version boundaries + +- [IEC/IEEE 61850-9-3:2016](https://webstore.iec.ch/en/publication/24998): IEEE 1588-2008 L2 PTP / peer-to-peer delay profile. +- [IEC 61850-9-2:2011 + AMD1:2020](https://webstore.iec.ch/en/publication/66549): Sampled Values Ethernet mapping. +- [IEC 61869-9:2016](https://webstore.iec.ch/en/publication/24663): digital interfaces for instrument transformers and application-specific synchronization/sample behavior. +- Later drafts or secondary summaries cannot silently override the approved 2016/2020 baselines. Verify profile-specific interpretations against controlled standards copies before making claims. + +## Evidence taxonomy + +- `STATIC`: inspected source and build configuration. +- `SOFTWARE TEST`: decoded-wire regression driven by the actual portable encoder. +- `EMBEDDED CI`: compilation for pinned ESP-IDF/ESP32-P4 SDK. +- `PHYSICAL`: needs hardware/reference clock/IED; **not available in this phase**. +- `GAP`: known missing/incomplete implementation or evidence; do not market as IEC conformance. + +| Feature / condition | Required contract or expectation | Evidence / state | +| --- | --- | --- | +| PTP transport | Ethernet L2 EtherType 0x88F7, multicast, v2 | SOFTWARE TEST: portable PTP frame roundtrip and Ethernet destination | +| PTP delay mechanism | Peer-to-peer delay; reply carries valid RX and TX hardware timestamps | STATIC: Pdelay response path; SOFTWARE TEST frame fields; PHYSICAL pending | +| Announce and Sync | 1 Hz nominal defaults (logMessageInterval=0) | STATIC + SOFTWARE TEST: both 1000 ms | +| Pdelay_Req rate | Receiver requests 1 Hz; SOURCE answers correctly | STATIC for receiver and source; PHY timestamp proof pending | +| PTP domain | Default 0, must match subscriber; 93 is a documented alternative to avoid collisions | STATIC + SOFTWARE TEST, never force one domain universally | +| PTP transportSpecific/VLAN | Must agree with IED and network/engineering plan | Configuration preserved in codecs; no universal nibble or VLAN assumption | +| Clock identity | Stable and nonzero source identity; Follow_Up sequence matches Sync | SOFTWARE TEST | +| Clock class / traceability | No unproven grandmaster quality: lab oscillator is class 248, clockAccuracy unknown | STATIC, checked against encoded Announce | +| PTP timescale and UTC properties | Timestamp in PTP/TAI seconds when epoch is available; truthful flagField and offset provenance | **PARTIAL SOFTWARE TEST**: bounded UTC-to-TAI conversion; EMAC seed from plausible local RTC; PTP-timescale flag; opt-in UTC-offset-valid flag. Traceability remains false. Unset RTC leads to no PTP-timescale claim. UTC accuracy, leap-offset currency and hardware evidence are **GAP**; no 9-3 conformance claim | +| Full PTP ordinary-clock/BMCA behavior | Select/announce/step/holdover/recover according to applicable clock type | **GAP** for a standards-complete Grandmaster; current LAB_SOURCE is a limited laboratory source | +| SV canonical Ethernet stream | 0x88BA, engineered destination, APPID, svID, confRev, VLAN, dataset ordering | Portable codec/profile tests exist; independent canonical-wire acceptance pending | +| 4000/4800 sampling | Correct rational cadence and modulus for the **explicitly chosen** 80-samples/cycle family | SOFTWARE TEST: 120 s mathematical schedule, zero accumulated tick drift | +| Higher IEC 61869-9 profiles | Correct samples/sec vs ASDUs/frame distinction, engineered nofASDU & payload type | **GAP** for embedded runtime generality. 4000 fps/1 ASDU is not a universal IEC 61869-9 profile | +| Counter epoch | `smpCnt=0` at valid profile-specific synchronization event; consistent on loss/restart | M1 only coarse first-arm to EMAC second; **GAP** continuous timebase tracking | +| `smpSynch` declaration | May not claim local/global qualified synchronization solely from PTP packet/clock lock | SOFTWARE TEST: AUTO fail-closed until sample phase is qualified | +| SV hardware TX timing | Measured sample deadline vs actual MAC departure, including P95/P99 and drift | **GAP** without device timestamped SV evidence | +| Actual IED acceptance/no alarm | Vendor IED locks PTP and accepts canonical SV continuously | PHYSICAL pending; cannot be inferred from builds or synthetic PCAP alone | + +## Software-only acceptance for the current PR + +1. Linux GCC and Clang; Windows MSVC: portable PTP, PTP power-profile wire test, epoch/schedule and sync-gate regressions. +2. ESP32-P4 build matrices: normal SV + PTP enabled, SOURCE/RECEIVER/monitor variants, validated firmware contract. +3. Native Qt ARStack Studio Windows build and installer packaging. +4. Static checks: default Source Sync/Announce/Pdelay are 1 s, source class remains 248, AUTO `smpSynch` is fail-closed. +5. No merged `main` or public title such as "fully 9-3 compliant" until remaining GAPs are fixed and physical evidence exists. + +## Next coding tranche, ordered by standards risk + +1. **PTP time-properties further hardening:** verify UTC-offset provenance against authoritative leap records, RTC synchronization, leap-second transitions and time steps; surface compact diagnostics in Studio. Pure software cannot attest absolute clock accuracy. +2. **Reusable PTP/SV timeline authority:** continuously compare and correct GPTimer phase against EMAC hardware time at a bounded rate; handle drift, clock steps, PTP restart, source switches, and holdover without pretending alignment. +3. **Profile-targeted SV compiler:** exact profile-specific sample counter reset, ASDU grouping, nofASDU, dataset layout and scaling from SCL + documented source profile. Do not hard-code 4000/4800 for arbitrary SCL. +4. **Passive interoperability fixtures:** anonymous golden frames/PCAP and independent decoder checks for 9-2LE-compatible streams and a separately supported IEC 61869-9 variant. Report mismatches rather than automatic coercion. +5. Hardware acceptance only when equipment becomes available. + +**Source-of-truth policy:** a standards-oriented claim is never promoted from STATIC/CI to PHYSICAL. The laboratory fallback `smpSynch=0` is deliberate and must not be overridden to silence an IED alarm. diff --git a/docs/PTP_CANONICAL_RUNTIME_AUTHORITY_M3A.md b/docs/PTP_CANONICAL_RUNTIME_AUTHORITY_M3A.md new file mode 100644 index 000000000..a165a1bf6 --- /dev/null +++ b/docs/PTP_CANONICAL_RUNTIME_AUTHORITY_M3A.md @@ -0,0 +1,32 @@ +# M3A.1 — Canonical PTP Clock Authority & Receiver START/STOP Fencing + +This milestone composes one role-tagged timing-authority snapshot from the +existing SOURCE readiness/fault generation and the M3B.1 RECEIVER worker-owned +lease. This is an **EMAC PTP clock** lease, **not** PPS-to-`smpCnt=0` phase +binding or evidence of precise SV egress. `sv_phase_qualified` remains false. + +The public `ar_ptp_lab_get_timing_authority()` returns a compact SOURCE, +TIME_RECEIVER or MONITOR snapshot: role, generation, usable, selected GM +identity/port and independently qualified global traceability. One private +facade status read is the only input; it is the same path as GUI/serial status, +but without verbose per-observation logging. + +Safety: +- SOURCE generation is double-checked around live status retrieval. A + stop/restart or missing/changed source generation revokes readiness. +- RECEIVER/MONITOR worker busy (pending/start/cleanup) is distinct from + runtime *ready*. A pending cleanup blocks a new SOURCE START/configure. +- A receiver STOP request revokes exposed clock usability immediately; the + worker finalizes hardware rollback later. Snapshot cannot resurrect a + stale LOCKED bit while STOP/cleanup is pending. +- RECEIVER clock readiness requires genuine selected GM, measured LOCKED, + nonzero generation and worker READY, not a status field alone. +- SOURCE lab time is not declared globally traceable; MONITOR is read-only. +- No heap, locks or ETH IOCTL are added in the high-frequency SMV ISR. + Control-plane facade and 5 ms receiver worker remain distinct. + +Acceptance: strict portable `ptp_canonical_authority_tests` covers all role +claims and invalid/stale evidence. Guarded ESP32-P4 firmware compilation +builds the facade plus receiver worker, preserving standard runtime defaults. +Physical board, GPS/PTP lock, MAC timestamp egress, RECEIVER phase discipline +and global smpSynch promotion are not yet accepted. diff --git a/docs/PTP_EMAC_ROLE_OWNERSHIP_M3D1.md b/docs/PTP_EMAC_ROLE_OWNERSHIP_M3D1.md new file mode 100644 index 000000000..2ff4e9b7f --- /dev/null +++ b/docs/PTP_EMAC_ROLE_OWNERSHIP_M3D1.md @@ -0,0 +1,25 @@ +# M3D.1 — Cross-role EMAC ownership gate + +Follow-up to M3C.2. The SOURCE status `ar_ptp_source_is_running()` +indicates verified fresh outbound Announce/Sync evidence, **not** that the +underlying SOURCE worker owns the EMAC PTP timestamp hardware. A SOURCE task +can be actively starting, holding over or stopping with `is_running=false`. + +Use the SOURCE worker's atomic `g_ptp_started` as an explicit lifetime claim +via `ar_ptp_source_runtime_active()`, matching the RECEIVER's +`ptp_receiver_runtime_active()`. The facade now rejects all cross-role +START and config changes while either worker is active, including a SOURCE +worker without valid fresh packet evidence, or while RECEIVER cleanup is +pending. The same role-ownership predicate is used for configuration, +startup and status routing. A separate nonblocking atomic START/config +transaction guard prevents overlapping control callers from both passing +the idle check before a worker has claimed the EMAC. + +This closes false 'source idle' observations without changing PTP freshness, +clock authority, SMV phase/encoder, release images or PTP protocol timing. +The measured `ar_ptp_lab_is_running()` API retains its READY semantics; +the newly exported SOURCE active query is explicitly **not** a clock lease. + +Portable regression enumerates all ownership combinations. Both guarded +firmware role builds are the integration gate. Physical ESP32-P4, external +GM, PTP lock and relay/PPS egress acceptance are still outstanding. diff --git a/docs/PTP_M3E_BENCH_EVIDENCE_GATE.md b/docs/PTP_M3E_BENCH_EVIDENCE_GATE.md new file mode 100644 index 000000000..1234c6d0a --- /dev/null +++ b/docs/PTP_M3E_BENCH_EVIDENCE_GATE.md @@ -0,0 +1,132 @@ +# M3E.1 — Evidence-driven ESP32-P4 SOURCE/RECEIVER bench handoff + +**Status: host-side validation implemented; no new physical board run was performed.** This gate exists so a real ESP32-P4 bench run cannot be confused with a successful software build, host timestamp or arbitrary `PTP LOCKED` printout. It builds on [M3D.2](PTP_START_STOP_CANCELLATION_M3D2.md), [PTP-P2 acceptance](PTP_P2_BENCH_ACCEPTANCE.md) and the existing [M2B streaming PTP+SV wire audit](PTP_SV_WIRE_AUDIT_M2B.md). + +## One native Studio / ESP32-P4 workflow + +Use the native **ARStack Studio** application for normal profile deploy and operator START/STOP. The board's serial command transport is a fallback for diagnostics (`IDENTIFY`, `PTP SHOW`, `PTP START`, `PTP STOP`, `SHOW`, `START`, `STOP`), not a replacement desktop web app. + +1. Retain the stable board image before experimenting. Confirm the ESP32-P4 target **and silicon revision** against `sdkconfig` in the chosen bundle. Current P0 hardware is pre-v3; do not force v3 defaults. Keep the Ethernet lab **isolated from any live process bus**. Do not use these experimental images on an in-service relay network. +2. Download the GitHub Actions artifact from [M3D.2 run #38655888456](https://github.com/masarray/arstack61850/actions/runs/38655888456): `esp32p4-m3d-ptp-bench-7d2d5146b1892124c1744ad26ab31b3489e8387e` (retention expires October 21, 2026 UTC). It contains `source/` and `receiver/`, each with its **own** `manifest.json`, `flasher_args.json`, `sdkconfig`, and three images. Never mix SOURCE and RECEIVER application binaries. Verify each image hash and use offsets from the **actual** `flasher_args.json` rather than guessing. This tool does **not** perform flashing. +3. Run the preflight before any flash (also supported on Windows with Python 3): + + ```sh + python scripts/ptp_m3e_bench_gate.py --bundle arstack61850-esp32p4-m3d2-bench-7d2d514.zip --role receiver --output preflight-receiver.json + ``` + + `firmware.status=VERIFIED` proves only consistency with the packaged manifests/checksums; it does **not** prove a production signature, a physically installed image, measured PTP accuracy or compatibility with the target board. The current artifact's app offset is `0x10000`, bootloader `0x2000`, partition table `0x8000`; preflight reads and checks all three independently from each role's manifest. + +4. After installing **one** role on the appropriate test board, collect *raw* serial/Studio logs including `IDENTIFY` (must contain the embedded 16-character build ID) and repeated `PTP SHOW` before START, during acquisition/LOCKED, after deliberate GM disconnect/change and during STOP/recovery. Preserve the complete transcript and record board silicon revision, external GM model/profile and test topology. +5. In parallel, record a **canonical** Ethernet classic-PCAP (VLAN preserved via mirror/TAP if necessary). A diagnostic broadcast mirror is **not** evidence of the canonical SV stream. Capture at least two `smpCnt=0` events plus one observed counter wrap per single uninterrupted segment. Capture loss/reacquisition and GM switch as **separate segments**: the preexisting wire auditor intentionally fails a single stream that mixes conflicting PTP sources. +6. Build the **existing** streaming host parser without adding another wire codec: + + ```sh + cmake -S . -B build-m3e -DARIEC61850_BUILD_TESTS=OFF -DARIEC61850_BUILD_TOOLS=ON + cmake --build build-m3e --target ariec61850_ptp_sv_wire_audit --parallel 2 + ./build-m3e/ariec61850_ptp_sv_wire_audit --self-test + ``` + +7. Run the combined host evidence gate. Replace SV identity, APPID, `smpCnt` modulus and PTP domain with **the exact active SCL/Studio profile**. The example below is a placeholder and **not** a default physical profile: + + ```sh + python scripts/ptp_m3e_bench_gate.py \ + --bundle arstack61850-esp32p4-m3d2-bench-7d2d514.zip \ + --role receiver \ + --serial-log receiver-serial.log \ + --pcap receiver-canonical.pcap \ + --wire-audit ./build-m3e/ariec61850_ptp_sv_wire_audit \ + --sv-id 'YOUR_ACTUAL_SVID' --app-id 0x4000 \ + --counter-modulus 4000 --ptp-domain 0 \ + --output receiver-bench-report.json + ``` + + The Python gate **executes** `ariec61850_ptp_sv_wire_audit --json` over the actual PCAP with bounded streaming C++ parsing. It never reparses PTP or BER/SV wire frames. `--serial-log` checks the board's `ARSTACK identity ... build=...` against both firmware manifests and scans existing `PTP2 role=... discipline=... source=...` status messages; `LOCKED` is explicitly classified as **reported by firmware**, not independently measured PTP lock. + +## What the machine-readable result means + +- `firmware.status=VERIFIED`: matching two-role commit, build ID in the image, role/target/Kconfig constraints, non-overlapping ESP-IDF flash offsets, SHA-256 for all images, no staged mirror/probe, distinct SOURCE/RECEIVER applications. It validates **provenance consistency**, not a trusted signing chain. +- `serial.status=OBSERVED`: matching board-reported build ID, role, PTP2 status history and selected source in RECEIVER LOCKED lines. A missing/wrong build ID, wrong role or LOCKED with no GM rejects the run. A status report alone cannot establish GM accuracy. +- `wire.status=CONSISTENT`: the existing native PCAP auditor reports the selected canonical SV counter sequence/identity consistent and the selected-domain PTP Announce/Sync/Follow_Up complete; all selected wire `smpSynch` remain **0**, and the PCAP contains at least two sample-zero events and one wrap. Any `smpSynch=1/2`, counter gap or insufficient capture fails this M3 test. PCAP timestamps are **host receive timestamps, not EMAC egress timestamps**. +- `softwareEvidenceConsistent=true`: both the serial and PCAP consistency checks succeeded **within their evidence limits**. It is not a conformance, hardware phase or relay acceptance PASS. +- `independentHardwarePhaseQualified=null`, `independentPTPGrandmasterAccuracyQualified=null` and `relayAcceptanceQualified=null` remain unknown in **every** report. `autoSmpSynchPromotionPermitted=false`, `publicReleasePermitted=false` are hard fail-closed outputs. There is intentionally no flag that claims these physical gates are green. + +The gate exits `0` for clean artifact-only preflight or complete consistent serial+PCAP *software* evidence. An incomplete/contradictory evidence set exits `2`; no absent input is silently treated as a real device PASS. The `sha256` fields bind input files so later test analysis can verify that logs/PCAP did not change. + +## Physical acceptance still required + +M3E is a **repeatable collection boundary**, not a completed board test. Before considering qualified PTP/SV synchronization, independently measure MAC SV egress timestamps vs external GM/PPS, sustained drift/jitter, epoch second continuity across loss/recovery, and relay/IED acceptance per P2 criteria. Record observed physical deviations and do not upgrade `smpSynch AUTO` or merge to a public stable release based on host capture or the software result. + +## M3E.2 — Ordered PTP lifecycle evidence (fail-closed host gate) + +An M3E.1 weakness has been closed: a clean PCAP plus a RECEIVER serial log +showing only `UNLOCKED`/`ACQUIRING` must **not** return +`softwareEvidenceConsistent=true`. M3E.2 adds an **ordered** operational gate +that is required alongside serial identity and wire consistency. It consumes +existing `IDENTIFY`, `PTP2` and `PTP status` lines; it adds no firmware task, +no ISR instrumentation, no wire decoder and no claim of physical PTP lock. + +The `--scenario` option defaults to `auto` (`receiver-acquisition` for +RECEIVER or `source-ready` for SOURCE). For deliberate negative testing choose: + +| Scenario | Required **firmware-reported** sequence | +| --- | --- | +| `receiver-acquisition` | `UNLOCKED` or `ACQUIRING` → measured `LOCKED` | +| `receiver-recovery` | measured `LOCKED` → `HOLDOVER`/`UNLOCKED`/`ACQUIRING`/`FAULT` → measured `LOCKED` | +| `receiver-gm-handoff` | measured `LOCKED` with GM A → non-LOCKED → measured `LOCKED` with a **different** GM identity/port | +| `source-ready` | SOURCE reports `PTP status=RUNNING` with nonzero sent Announce, Sync, Follow_Up | + +For RECEIVER a usable `LOCKED` line must include selected, nonzero GM +identity/port and valid numeric offset/path/jitter plus positive Sync, +Follow_Up, Pdelay and accepted discipline counters. Truncated LOCKED lines, +zero-evidence counters, direct LOCKED-to-LOCKED GM changes without observed +demotion, mixed boot IDs or missing 16-hex-digit boot identity are incomplete. +A new source does **not** inherit the previous locked lease. The gate performs +one linear pass over log observations, with a 16 MiB serial-input ceiling. + +Example: run an acquisition, then separately test loss/recovery on the same +bench (preserve unedited serial and capture artifacts): + +```sh +python scripts/ptp_m3e_bench_gate.py \ + --bundle arstack61850-esp32p4-m3d2-bench-7d2d514.zip \ + --role receiver --scenario receiver-recovery \ + --serial-log receiver-loss-recovery.log \ + --pcap receiver-recovery-segment.pcap \ + --wire-audit ./build-m3e/ariec61850_ptp_sv_wire_audit \ + --sv-id 'ACTUAL_SVID' --app-id 0x4000 \ + --counter-modulus 4000 --ptp-domain 0 \ + --output m3e2-recovery-report.json +``` + +Use a **single uninterrupted stable-GM PCAP segment** as the wire input for +each audit. Retain a separate PCAP for the handoff itself; the existing PTP +wire auditor intentionally detects mixed GM identity and must not be bypassed +by relabeling sources or concatenating captures. A positive lifecycle result +is `operationalLifecycle.status=OBSERVED`, which means only that the board +reported the required ordered evidence. It cannot prove every transition +occurred or was measured between serial polls. `softwareEvidenceConsistent` +now requires **all three** gates: serial identity, ordered operational status, +and canonical PCAP continuity. All hardware qualification fields remain +`null`, and AUTO `smpSynch` promotion/public release remain `false`. + +All M3E.2 changes are Python host-tool regression/doc additions. The proven +M3D.2 SOURCE/RECEIVER firmware binaries and native ARStack Studio are not +rebuilt or modified by this checkpoint. +## M3E.3 — SOURCE readiness freshness / boot provenance regression (2026-10-17) + +A previous host gate accepted **any** historical `PTP status=RUNNING` +sample with positive Announce/Sync/FollowUp as SOURCE readiness. This could +return `operationalLifecycle.status=OBSERVED` even when a later STOP, rejected +START, unavailable status, or TX fault was already present in the same raw log. + +The M3E.3 host-only gate now requires the **latest** SOURCE status to be +RUNNING with complete native firmware counters and `TXfail=0`. It revokes +readiness on `PTP stop requested`, `PTP start rejected`, STOPPED, truncated +SHOW, or unavailable status. Any nonzero reported PTP TX failure in the same +boot is conservatively latched as a veto. Every IDENTIFY line must also carry +the same 16-hex-digit boot ID; an identity without a boot ID cannot inherit +a preceding measurement. Receiver logic and wire decoding are unchanged. + +This is regression protection for **firmware-reported** software evidence, +not proof of wire transmission, EMAC phase alignment, IED lock, or public +release readiness. No firmware images or Studio workflow have changed. diff --git a/docs/PTP_RECEIVER_CLOCK_AUTHORITY_M3B.md b/docs/PTP_RECEIVER_CLOCK_AUTHORITY_M3B.md new file mode 100644 index 000000000..291818615 --- /dev/null +++ b/docs/PTP_RECEIVER_CLOCK_AUTHORITY_M3B.md @@ -0,0 +1,23 @@ +# M3B.1 — Canonical receiver clock generation fence + +This checkpoint adds measured TIME_RECEIVER **EMAC-clock** authority without +claiming SV/PPS alignment or GPS-grade traceability. + +The portable `PtpReceiverClockAuthority` runs only in the 5-ms receiver worker. +It issues a nonzero generation token on readiness transitions, grandmaster +clockIdentity/port change, LOCKED acquisition/loss, phase step, actuation failure +and STOP/START. Source switches or EMAC steps observed with a stale LOCKED state +remain blocked until an observed unlocked/relocked discipline cycle. + +The C PTP status snapshot now exposes `timing_authority_generation` together +with `timing_authority_clock_usable`. SOURCE reports its existing nonzero SOURCE +generation only while ready. RECEIVER reports the portable worker-owned lease; +MONITOR cannot report a usable lease. A clock lease is a prerequisite for later +SV epoch binding, not its substitute. + +Acceptance: portable host regression (GM switch, GM port change, stale lock, +step, actuation failure, holdover, stop/restart); strictly warned compile; +guarded SOURCE firmware compile including receiver translation unit. +PTP/SMV ISR, GUI and normal firmware role defaults are unchanged. Receiver +smpCnt=0 phase binding and physical ESP32-P4 validation remain future work. +`smpSynch` AUTO stays fail-closed (0) until an independent SV phase proof exists. diff --git a/docs/PTP_SOURCE_FRESHNESS_M2F.md b/docs/PTP_SOURCE_FRESHNESS_M2F.md new file mode 100644 index 000000000..83550f801 --- /dev/null +++ b/docs/PTP_SOURCE_FRESHNESS_M2F.md @@ -0,0 +1,19 @@ +# M2F — fail-closed SOURCE freshness and truthful PTP time-properties + +## Security / interoperability issues + +1. Before this checkpoint the previously introduced SOURCE readiness gate was fresh-send-aware but NOT age-aware. A FreeRTOS SOURCE task that stalled after a successful Announce+Sync could leave `g_ptp_ready=true` indefinitely. This is especially dangerous because the SV phase observer queries SOURCE health on its own task. +2. Successful `ETH_MAC_ESP_CMD_S_PTP_TIME` from an apparently plausible RTC was treated as sufficient to assert `ptpTimescale` in **all** Announce/Sync/FollowUp messages—even when `CONFIG_AR_PTP_MANUAL_UTC_OFFSET_VALID` was disabled. An arbitrary RTC with an unverified TAI−UTC offset is not a proven PTP timescale. + +## Implemented fail-closed checks + +- Reusable zero-allocation `PtpSourceEvidenceFreshness::fresh(now, lastAnnounce, lastSyncPair, timeout)` demands two positive monotonic timestamps within the configured bounded age. In default 1Hz power-profile SOURCE this is 3 seconds; an explicit nonstandard bench cadence uses 3 × max(Announce,Sync) interval. Zero timestamps, negative/zero timeout, backwards monotonic time, stale Announce or stale Sync all fail. +- The ESP32-P4 SOURCE control plane updates the two success timestamps only after complete actual wire sends. A failure clears both timestamps. `ar_ptp_lab_is_running()`, SOURCE status, startup readiness and `ar_ptp_lab_source_generation()` use the age-validated status. If the task was suspended long enough to expire, it invalidates the prior gate BEFORE attempting new sends, so a complete fresh core cycle is necessary for requalification and a new SOURCE generation. +- `conservative_lab_time_properties(hw_tai_seeded, tai_utc_offset_verified)` sets PTP timescale AND currentUtcOffsetValid only if both pieces of evidence exist. If either is missing, both flags remain false; timeTraceable and frequencyTraceable remain false in all cases. The lab clock continues to identify itself as clockClass 248, unknown accuracy, internal oscillator. +- Portable tests cover zero, stale, backwards, boundary-expired and fresh monotonic evidence and check actual raw Announce Ethernet flag bytes for unverified (0x0000) versus manually verified seeded timescale (0x000c). The already opt-in single CI checkpoint runs C++ PTP and SV regressions and the ESP32-P4 SOURCE firmware build with ESP-IDF v5.5.4. + +## Limitations and measured-evidence boundary + +- These checks do not establish IEEE/IEC 61850-9-3 certification, IEEE1588 traceability or a globally synchronized grandmaster. In default mode the locally seeded LAB_SOURCE explicitly claims **unknown** PTP timescale; strict IEDs can reject that clock. This is preferable to spoofing quality and missing an unsafe time alarm. +- Source freshness only measures recent *successful sends*; it is not proof that IEDs received, selected or locked to SOURCE. SOURCE scheduler jitter, SV actual TX egress phase, and IED sync alarms still require silicon/receiver evidence. +- No change to the default-off experimental SV phase servo or public firmware release. No UI/installer changes. diff --git a/docs/PTP_SOURCE_HEALTH_M2E.md b/docs/PTP_SOURCE_HEALTH_M2E.md new file mode 100644 index 000000000..65c693090 --- /dev/null +++ b/docs/PTP_SOURCE_HEALTH_M2E.md @@ -0,0 +1,26 @@ +# M2E — IEC/IEEE 61850-9-3 lab SOURCE readiness and cadence hardening + +Engineering scope: default-off experimental phase tracking remains unchanged; public release remains untouched. M2E fixes two concrete *PTP SOURCE* faults upstream of the SV phase servo, and enforces standards-oriented nominal SOURCE cadence by default. + +## The failures + +1. Before M2E, `g_ptp_ready` could become true on ANY successful TX (including Pdelay_Resp) once lifetime totals for Announce/Sync/Follow_Up were each nonzero. After failed Sync or missing Follow_Up, the SOURCE could requalify without new Sync evidence. This was not an accurate time-authority readiness contract. +2. `PtpPublisherRuntime::poll_due` set `next_due = poll_now + interval`. A task running e.g. 7ms late each second accumulates nominal-source cadence error; long stalls shift the phase permanently. This is not a PTP epoch alignment mechanism, but unnecessarily drifts the protocol cadence. + +## Implemented controls + +- New portable **`PtpSourceReadinessGate`** in the existing PTP power-profile header: SOURCE qualifies only after an Announce and a *complete* hardware TX timestamped Sync + Follow_Up pair that have both succeeded since the last failed Ethernet/PTP send. Failure of a Pdelay exchange also invalidates the current evidence; later successful Pdelay alone **never** requalifies READY. +- `g_ptp_source_generation` increments **only on a fresh gate transition to READY**, allowing the existing phase and MAC egress evidence paths to reject a SOURCE restart. No stale lifetime counters are consulted for readiness. +- The portable SOURCE scheduler now advances by integer multiples of the **original monotonic deadline**. A 1.007s poll does not shift the next deadline to 2.007s; the next deadline remains 2.000s. A long stall sends **one** due message pair, not catch-up bursts. +- New default-on **strict SOURCE admission**: Announce 1s, Sync 1s, hardware peer-delay responder enabled (`inspect_ptp_power_profile_cadence` reused). Does not hardcode PTP domain/transport/VLAN; settings still must match the receiving IED. The explicit `CONFIG_AR_PTP_ALLOW_NONSTANDARD_LAB_CADENCE=y` permits isolated nonstandard diagnosis without silently labelling it a power-profile default. +- One opt-in Draft CI job (no new workflow) compiles and runs portable runtime, power-profile, phase and TX-evidence regressions with `-Werror` and builds ESP32-P4 SOURCE using ESP-IDF 5.5.4. + +## What this does *not* certify + +- The task scheduler cadence is NOT hard-phase-locked to the PTP second boundary and software wake latency still exists. The phase-authority/egress work is experimental and requires hardware measurement. +- SOURCE `clockClass=248`, `clockAccuracy=unknown`, `timeSource=internalOscillator` and not traceable remain truthful; the IED is entitled to reject a non-traceable master when it needs a traceable one. Do not lower alarm thresholds or spoof an external reference. +- A zero alarm claim, IEEE/IEC conformance certificate, PTP BMCA conformance or physical 1us-grade synchronisation requires external clock/IED evidence. +- Assessed against IEC/IEEE 61850-9-3:2016 cadence requirements and independent vendor descriptions: + - https://standards.ieee.org/ieee/61850-9-3/6167/ + - https://infocenter.nokia.com/public/7705SAR234R1A/topic/com.nokia.basic-system-guide/iec-ieee-61850-9-3-c37-238-2017.html + - https://microchip-ung.github.io/bsp-doc/bsp/2026.09/ptp.html diff --git a/docs/PTP_START_STOP_CANCELLATION_M3D2.md b/docs/PTP_START_STOP_CANCELLATION_M3D2.md new file mode 100644 index 000000000..1b565eae2 --- /dev/null +++ b/docs/PTP_START_STOP_CANCELLATION_M3D2.md @@ -0,0 +1,46 @@ +# M3D.2 — PTP START/STOP cancellation acknowledgment + +The PTP facade's M3D.1 START/config admission gate did not include STOP: +a concurrent STOP could see an unstarted SOURCE or RECEIVER and return +while its START caller later entered READY and reported success. + +One fixed-capacity atomic byte now coordinates START, config, and STOP. +- A START/config atomically acquires the idle control slot. No FreeRTOS + spinlock is held while waiting for the PTP worker's bounded startup. +- STOP publishes cancellation immediately. If control is idle, STOP + acquires the slot and performs existing bounded SOURCE/RECEIVER rollback. + If START owns it, STOP returns (request accepted); the START owner drains + cleanup **before returning** and reports failure, never late success. +- The slot is released by CAS against the same STOP bit. This closes the + check/unlock lost-cancellation window. Repeated STOPs while rollback is + underway coalesce; cleanup invokes at most once per ownership interval. +- SOURCE/RECEIVER live status, generation, traceability and clock usability + are masked immediately when STOP is pending. The high-frequency SV ISR + and smpSynch AUTO (0) remain unchanged. +- Portable regression checks cancellation while START is active, duplicate + STOP, idle STOP, request during rollback, and 200 controlled two-thread + interleavings without arbitrary sleeps. Guarded ESP32-P4 SOURCE and + RECEIVER builds validate the integration. + +A concurrently requested STOP is a nonblocking cancellation request: the +already-running START is responsible for the eventual cleanup/acknowledgment. +No hardware test, real GM, PPS capture, SV wire-phase conformance, or +flashable public release is implied by CI compilation. + +## Opt-in hardware bench bundle (M3D.2) + +The **one-shot Draft** `phase-guard-checkpoint` compiles experimental +SOURCE and RECEIVER configurations separately and copies the exact binaries +listed by ESP-IDF's own `build/flasher_args.json`. Both role bundles contain +`manifest.json`, `sdkconfig`, `flasher_args.json` and verified +bootloader/partition/application `.bin` files, with SHA-256 per image. +`prHeadCommit` is the PR head rather than GitHub's synthetic pull-request +merge SHA; `checkoutCommit` records that distinct build checkout. +The embedded semantic build ID is `prHeadCommit[:16]`. + +Artifacts are retained for **7 days** and named +`esp32p4-m3d-ptp-bench-`. They are experimental +**software-compiled images**, not an official stable installer/firmware +release and not hardware tested. Match the bundle's `role`, flash offsets, +SDK target and board revision before any physical test; do not flash based +on an assumed fixed partition layout or a stale output folder. diff --git a/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md b/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md new file mode 100644 index 000000000..fdc4ca65b --- /dev/null +++ b/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md @@ -0,0 +1,72 @@ +# M3C.1 — Continuous integer PTP-second binding for SV zero epochs + +Date: 2026-10-15. Base: M2F PR #157 at `90062f391a9c97cb1ff20d072b7f15ba4a777fac`. This is a stacked Draft checkpoint, not a merge/release or proof of PTP compliance. + +## Bug / evidence gap +The M2A software phase observer previously normalized phase modulo 1 second. It checked GPTimer elapsed intervals and source/session generations, but it did not retain the identity of the integer PTP second corresponding to each ISR-published sample-zero deadline. Phase modulo 1 second is not by itself an epoch-continuity proof. + +## Correction +`SvPtpPhaseAuthority` now derives and stores the nearest **integer PTP second** for the last SV sample-zero GPTimer deadline, using the bracketed EMAC PTP read and signed low-32 GPTimer delta. The first valid observation establishes a baseline only. A following observation binds only if its sample-zero generation AND integer PTP-second identity both advance **exactly one**. Missed epochs, clock steps or impossible labels invalidate and clear the PI integrator and any pending correction. + +The existing ESP32-P4 SOURCE control-plane observer now publishes ISR phase-steering validity only after this continuous binding; on first observation it deliberately publishes zero correction. No new runtime timer, heap allocation, per-frame PTP IOCTL, software mutex on the ISR, PTP servo or SmpSynch upgrade was added. + +## Focused regression +`sv_ptp_phase_authority_tests`: baseline vs continuous qualification, +1 s / -1 s PTP clock step, missing SV epoch, reacquisition, observation spanning the PTP-second rollover, and GPTimer 32-bit wrap. Existing ±80 ppm 4000/4800 fps tracking and fault simulations remain in the same suite. + +## Scope and next gating +This is M3C **foundation**, NOT completion of the M3A canonical dual-mode runtime authority. It currently guards the opt-in SOURCE servo; RECEIVER phase correlation, clock-authority selection/switching, absolute PPS/smpCnt physical accuracy, MAC egress, non-default SV profile epoch semantics, full M3 recovery and legitimate AUTO smpSynch remain separate engineering gates. The firmware experiment stays default OFF and native ARStack Studio is unchanged. A successful build/test establishes software verification only; hardware/IED acceptance remains unverified. + +## M3C.3 — Bounded confidence on software phase tracking (2026-10-17) + +The previous software-tracking decision checked only the nominal GPTimer/PTP +phase residual against the configured threshold. It could report +`software_tracking=true` after consecutive 400-µs clock-read brackets even +though the corresponding measurement uncertainty was at least 201 µs, +larger than the default 60-µs tracking threshold. + +The qualification counter now advances only when +`abs(measured_phase_error_us) + observation_uncertainty_us` is within the +configured software-tracking threshold. One low-confidence observation revokes +tracking. Integer-second continuity, the separate phase-observation acceptance +limit, bounded PI steering, ISR work and firmware feature gates are unchanged. +This is a software confidence bound, not a hardware egress measurement or +authorization to assert `smpSynch=1/2`. + +Portable regression includes six accurate-phase/uncertain-clock observations, +four fresh narrowly bracketed reacquisition observations, and confidence +demotion after one degraded bracket. Hardware bench acceptance is still absent. + +## M3C.5 — Vendor-neutral AUTO local-sync qualification (2026-10-17) + +No new operator prompts; source PTP On → SMV Start still operates directly. +The IEC 61850-9-2 local sync declaration (`smpSynch=1`) is not the same +as merely sending PTP frames. IEC/IEEE 61850-9-3 defines the applicable +power-utility PTP profile. The SIPROTEC 5 process-bus manual confirms +`Accepted SmpSynch=1` is an intentional local-source interoperability mode +when merging unit and relay share a time reference; ARStack contains no +Siemens-specific wire quirk. + +A **SOURCE-only, explicit hardware-evidence build** can now promote AUTO to +local `1` only when ALL gates pass: stable PTP SOURCE generation, coherent +second-bound GPTimer sample-zero observations, software tracking with the +measured absolute phase plus read uncertainty <=4us, two adjacent coherent +16-second comparisons of genuine canonical SV MAC TX descriptor timestamps +both within +/-4us, no publish faults/missed slots/descriptor stalls, and +current hardware evidence age <=17 sample epochs. These 4us values are a +conservative project admission policy, not a universal IEC profile claim. +When disabled or insufficient, AUTO stays `0`; global `2` is never implied. + +The gate revokes on STOP, role change, missed sample, failed transmission, +wrong generation, discontinuous PTP epoch, stale evidence or unqualified +software phase. No heap, blocking IO, or additional work in the GPTimer ISR. +The sparse actual-frame descriptor poll remains a possible publisher timing +disturbance; evaluate actual board and relay traces before release. + +SOURCE and RECEIVER remain distinct: this milestone qualifies SOURCE local +evidence only; receiver `LOCKED` without equivalent measured SV egress +does not promote AUTO. Successful firmware CI does not establish physical +phase accuracy or guarantee zero relay sync alarms. + +External specifications: +- https://standards.ieee.org/ieee/61850-9-3/6167/ +- https://cache.industry.siemens.com/dl/files/460/109742460/att_1135746/v1/SIP5_Processbus_V09.50_Manual_C054-D_en.pdf?download=true diff --git a/docs/PTP_SV_EPOCH_M1_ACCEPTANCE.md b/docs/PTP_SV_EPOCH_M1_ACCEPTANCE.md new file mode 100644 index 000000000..8abc2d309 --- /dev/null +++ b/docs/PTP_SV_EPOCH_M1_ACCEPTANCE.md @@ -0,0 +1,74 @@ +# ESP32-P4 SV/PTP synchronization — M1 engineering evidence + +**Status: software integration candidate. NOT relay-accepted.** Base: `main@9e70a4937874`. + +## Scope / why alarms can still occur + +IEC 61850-9-2 Amendment 1:2020 specifies the meaning of SV synchronization declarations, but the counter reset event and acceptable sample-time error depend on the applicable application/product profile (e.g. IEC 61869-9). IEC/IEEE 61850-9-3:2016 is an L2/P2P PTP profile with 1-second Announce / Sync / Pdelay intervals and 3 Announce timeout intervals. A free-running local oscillator is **not** GNSS/UTC traceable; do not promote a lab source to `clockClass=6` or global `smpSynch=2`. + +Authoritative references: +- https://webstore.iec.ch/en/publication/24998 +- https://standards.ieee.org/ieee/61850-9-3/6167/ +- https://www.omicron-lab.com/fileadmin/assets/Precision-Timing/Knowledge_Applications/Article_IEC_61850-9-3/IEC_61850-9-3_-_Will_simplicity_supersede_complexity_.pdf +- https://docs.espressif.com/projects/esp-idf/en/v5.5/esp32p4/api-reference/peripherals/gptimer.html + +## M1 implemented + +1. Production PTP source defaults to 1 Hz Sync, 1 Hz Announce, and enables Pdelay response. Receiver Sync timeout increased to 3s because a 1s timeout with 1 Hz packets can falsely enter HOLDOVER due to modest scheduling jitter. +2. Requested hardware Pdelay support must be active before PTP source reports ready; otherwise START fails closed. PTP TX failure clears readiness. +3. When a verified LAB_SOURCE is already running **before** SV START, the initial `smpCnt=0` timer alarm is approximately aligned to the next whole second of the ESP32-P4 EMAC PTP clock by a bounded cross-clock observation and safe first-delay planning. **This is coarse initial alignment, not a disciplined/slewed long-term sample timebase**; GPTimer remains an independent oscillator. +4. `smpSynch=AUTO` cannot promote a PTP clock lock into an SV synchronization claim without separately qualified SV timeline phase. This M1 does not yet produce that qualification. Forced 1/2 values are explicit laboratory injections and never evidence of measured synchronization. +5. New headless tests cover the epoch arm maths, invalid clock reads, and fail-closed sync claim gating. + +## M2 required before claiming no synchronization alarm + +- Develop a bounded PTP↔GPTimer continuous clock bridge, with phase/frequency correction to keep sample time aligned for long runs, including restart and phase-step behavior. +- Require the intended SV profile's exact `smpCnt` epoch policy, `smpSynch` meaning, payload order/quality/scaling, and PTP profile parameters from SCL plus applicable product standard. +- Capture actual Ethernet TX hardware timing evidence for canonical SV (not diagnostic mirror), relative to EMAC PTP time; measure peak, P95/P99 and sustained drift at 4000 and 4800 fps. If timestamp APIs stall the real-time task, use bounded sampled evidence or an independent hardware analyzer rather than adding per-frame IOCTL overhead. +- Promote AUTO to 1 **only** after local source/clock quality + sample timeline validity + profile-derived timing tolerance are measured and continuously monitored. Only a separately verifiable globally traceable source can authorize 2. +- Source-loss, multiple grandmasters/BMCA, packet drop, wrong transportSpecific/domain/VLAN, Pdelay failure, holdover, link restart and bounded transmit failures must never falsely advertise lock. +- Preserve 1h 4000 fps reference evidence, then independent 4800 fps acceptance; continuous counter and no missed slots/TX failures. +- Use isolated laboratory IED and full record: IED model/firmware, SCL, PTP settings, firmware commit, `.pcapng`, IED time-lock state, SV acceptance and exact alarm text. Do not infer no-alarm from a successful CI build. + +## Operator sequencing for M1 + +1. Use only isolated, non-production Ethernet and approved test IED. +2. Match IED PTP mode (IEC/IEEE 61850-9-3 Layer 2, P2P), domain, transportSpecific, VLAN and multicast path. For current lab source, clockClass 248 is truthful (not externally traceable); some IEDs correctly refuse to treat this as a qualified protection-grade GM. +3. In native ARStack Studio, start PTP before SV START. Wait for Announce + Sync + Follow_Up and Pdelay readiness. Then arm SV. +4. Inspect independent canonical capture and IED diagnostics. If the IED still alarms, report the alarm rather than changing `smpSynch` or PTP clock quality to hide it. + +M1 improves interoperability defaults and synchronization safety; it does **not** certify IEC/IEEE 61850-9-3 performance or guarantee a locked IED. M2 + physical bench evidence are mandatory. + + +## M1+ timeProperties / TAI hardening (software-only) + +- Portable PtpTimeProperties now encodes IEEE 1588v2 flagField bits explicitly in Announce, Sync, Follow_Up and Pdelay; packet visibility alone does not assert traceability. +- ESP32-P4 LAB_SOURCE treats time(nullptr) as an untrusted candidate UTC clock, validates a plausible date and a bounded configured TAI-UTC offset, converts to TAI seconds, and checks the hardware seconds-register range. The EMAC PTP timescale flag is set only if the TAI epoch is actually seeded. +- currentUtcOffsetValid is FALSE by default; CONFIG_AR_PTP_MANUAL_UTC_OFFSET_VALID=y requires explicit operator validation and a successful TAI seed. timeTraceable/frequencyTraceable remain FALSE. Local source remains clockClass=248, accuracy UNKNOWN, timeSource=INTERNAL_OSCILLATOR. +- LAB_SOURCE rejects configuration that claims unverified higher grandmaster quality. Test vectors inspect wire octets independently of the existing PTP packet decoder. +- Uninitialized RTC: run as a plainly non-timescale-validated laboratory source; **do not** market as a fully conformant IEC/IEEE 61850-9-3 grandmaster. + +Still missing: independent reference proving actual UTC time and leap-second offset, continuous phase/frequency coupling between EMAC PTP and SV GPTimer, mandatory clock-state/BMCA behaviors, and physical accuracy/IED acceptance. + +## M3C.4 — First alarm latency hardening (17 October 2026) + +The experimental epoch-arm code originally computed time to the next EMAC +PTP second **before** configuring GPTimer, then armed the later timer using +the unchanged interval. This converts driver/interrupt setup time into a +systematic SV sample-zero offset. The M3C.4 planner now retains the monotonic +end of the PTP read, subtracts measured pre-arm setup age, and rechecks the +same role/source-scoped clock lease. If too close to the intended second, +the first alarm defers by one whole second. Stale or backwards observations +do not arm a PTP-aligned timer: the publisher falls back to unqualified free +running. ISR cadence, stream counter and AUTO smpSynch fail-closed policy +are unchanged. + +Regression covers a 200-us startup delay, near-epoch rollover, stale clock +evidence, reversed monotonic time and invalid alarm fields. The remaining +gap between this last pre-arm monotonic sample and actual GPTimer clock start +still needs measurement. Host Npcap receive timestamps do NOT establish MAC +egress timing; firmware and CI success do NOT authorize smpSynch=1/2 or a +public PTP-locked release. Test on the isolated ESP32-P4 bench with an +independent GM/PPS before claiming physical lock. + +Bench checkpoint: PR #229 guarded SOURCE/RECEIVER artifacts are experimental images only and require board P0 revision validation, independently measured SV TX phase, and a separate stable release review. diff --git a/docs/PTP_SV_HARDWARE_TX_EVIDENCE_M2C.md b/docs/PTP_SV_HARDWARE_TX_EVIDENCE_M2C.md new file mode 100644 index 000000000..d521c5616 --- /dev/null +++ b/docs/PTP_SV_HARDWARE_TX_EVIDENCE_M2C.md @@ -0,0 +1,55 @@ +# M2C — sparse hardware SV TX timestamp evidence (ESP32-P4, software build scope) + +## Why this is an actual milestone + +M2A tracks the relationship between the 1 MHz GPTimer **sample-zero alarm deadline** and the ESP32-P4 EMAC PTP clock. M2B audits off-device wire frames. Neither measures when a canonical SV actually exits the Ethernet MAC. M2C adds an **opt-in descriptor timestamp on the actual canonical Sampled Values frame**, not on an unrelated diagnostic packet. + +Verified pinned driver API reference: +- ESP-IDF **v5.5.4** `components/esp_eth/src/mac/esp_eth_mac_esp.c`: `emac_esp32_transmit_ctrl_vargs()` forwards the supplied control pointer to `emac_esp_dma_transmit_frame_ext()`. +- ESP-IDF **v5.5.4** `components/esp_eth/src/mac/esp_eth_mac_esp_dma.c`: the extended TX path waits for the final DMA TX descriptor to return a timestamp, and returns zero seconds/nanoseconds on timeout. **It busy-waits**; using it on every 4000/4800-fps frame would add unacceptable work to the hard timing path. +- https://github.com/espressif/esp-idf/blob/v5.5.4/components/esp_eth/src/mac/esp_eth_mac_esp.c +- https://github.com/espressif/esp-idf/blob/v5.5.4/components/esp_eth/src/mac/esp_eth_mac_esp_dma.c + +## Conservative implementation + +`CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL`: **default OFF**. Requires the existing experimental PTP phase authority and *disabled* broadcast mirror/diagnostic probes, to avoid mixing lab-only traffic with the measured canonical stream. + +Only while a PTP SOURCE was started before SV START, the sample profile has exactly one canonical ASDU/frame and the counter modulus equals the frame rate: + +1. Count frames with `smpCnt=0`. Every **16th** such frame, request one hardware TX descriptor timestamp by `esp_eth_transmit_ctrl_vargs(eth_handle, ×tamp, 2U, buffer, bytes)`. +2. All other canonical frames use the **unchanged** `esp_eth_transmit()` baseline. No additional IOCTL, log, heap allocation, lock or timestamp polling inside the GPTimer ISR. The foreground publisher briefly polls the selected DMA descriptor **once per 16 seconds**; this could still induce jitter/missed slots, so it is disabled for actual process-bus operation. +3. The portable `SvTxEgressEvidence` collector takes an explicit record: `smpCnt`, epoch generation, 32-bit GPTimer *scheduled deadline*, hardware PTP seconds/nanoseconds, software TX submit duration, canonical TX outcome, recent missed-slot and generation checks. A stale epoch, missing descriptor timestamp, failed TX or invalid nanoseconds is **NOT MEASURED**, never silently substituted with host time. +4. Accepted samples calculate signed `PTP-egress-phase_ns` with respect to the nearest PTP whole-second boundary (positive after boundary, negative before). The collector retains bounded min/mean/max and failure counts, no allocation. One 1 Hz telemetry task formats a diagnostic log **only when a sparse observation changes**. +5. Neither PTP clock quality nor IED receiver lock is inferred. `smpSynch` remains fail-closed; no automatic promotion to 1/2. + +## Evidence interpretation (important) + +- Hardware MAC egress timestamp is genuine descriptor evidence **only if the EMAC driver returns nonzero, valid seconds and nanoseconds for the canonical SV EtherType 0x88BA**. Some hardware timestamp filters may accept only recognized PTP frames; in that case the diagnostic records `no_hw_ts`, not invented nanosecond precision. +- The source clock is laboratory clockClass **248**, not certified traceable UTC/GNSS. Hardware timestamping does **not** make it an IEC/IEEE 61850-9-3 qualified grandmaster. +- `last_alarm_ticks` is the most recent sample-zero GPTimer **deadline**, **not converted into an absolute PTP timestamp**. Conversion would need a continuously valid cross-clock mapping. A near-zero egress phase is only one piece of evidence; it does not prove every sample deadline meets timing requirements, nor resolve exact-second ambiguity after an epoch slip. +- `submit_max_us` measures CPU-side blocking while transmitting the selected frame; it is not MAC timestamp uncertainty, P99 of normal frames, or link propagation delay. +- To avoid silent interpretation errors, no `phaseQualified` or `IEDAlarmFree` status is emitted. A high transmit duration or missed slots during the probe may itself show the probe is too intrusive. + +## Software-only acceptance before physical devices + +- Portable `tests/test_sv_tx_egress_evidence.cpp`: positive and negative phase, valid exact-second timestamps, timestamp missing/overflow, missing canonical frame, mismatched epoch, failed TX, wraparound of low-32-bit GPTimer deadline, and bounded aggregate state. +- One explicit **Draft checkpoint** compiles both portable phase and hardware-egress evidence tests using `-Werror` and compiles ESP32-P4 SOURCE firmware with `CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y`, `CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL=y` and broadcast mirror disabled. This validates source against the pinned toolchain, not correct silicon timestamp behavior. +- No new dedicated always-on workflow. Standard ready-PR PTP core CI also compiles the portable evidence test when PR is promoted. +- Baseline firmware, Windows native ARStack Studio and public release remain unchanged. + +## Remaining hardware gates + +1. Test on ESP32-P4 pre-v3 silicon and inspect real `no_hw_ts`, phase jitter, and induced sample loss against a hardware-timestamped capture. +2. If SV EtherType timestamps are absent, audit Espressif descriptor timestamp filtering before proposing a driver-level patch; **do not lie with software send-time placeholders**. +3. Correlate sample deadline with EMAC PTP time under controlled clock steps and holdover; measure 4k/4.8k receiver behavior, especially IED synch alarms and GM acceptance. +4. Remove laboratory broadcast mirror and diagnostics from the bench profile; keep the normal production release unchanged until interop evidence exists. + +## M2D temporal-evidence hardening (replaces the M2C single-frame interpretation) + +A single descriptor timestamp near the PTP second boundary is **not a qualified synchronization observation**. M2D makes each first valid timestamp *baseline only*. Only the next sparse sample (`16` sample-counter-zero epochs later) is included in measured min/mean/max if the following are unchanged and consistent: publisher START session, canonical publisher fault-generation (catches skipped slots or failed TX anywhere in the intervening 16 seconds), ISR sample epoch generation delta = 16, nearest PTP epoch seconds delta = 16, matching in-call epoch and successful hardware timestamp. The nearest boundary adjusts for late-negative timestamps where the descriptor still reports the previous integer second. This protects against false-positive phase evidence after 1-second PTP steps, PTP time going backward, SV epoch slips, start/stop and packet loss. A discontinuity discards the old baseline, uses the new point for reacquisition, and **does not** count that point as measured. Invalid TX/descriptor reads remove the baseline entirely. + +Results explicitly distinguish `baseline`, `measured`, `SV_discontinuity` and `PTP_clock_step`. GPIO/driver clock steps that undo themselves between 16-second probes are still not detectable without a continuous monotonic cross-clock reference, and a measured pair does not establish MAC egress timestamp availability on real silicon, clock accuracy, IEC/IEEE 61850-9-3 conformance or IED lock. The new test injects ±PTP steps, session restart, loss between samples, first-reading-only, missing timestamps and uint32 sample-epoch rollover. M2D makes **evidence truthful**; it does not assert `smpSynch=1/2`. + +### PTP SOURCE generation and restart safety + +The SOURCE control plane now exposes `ar_ptp_lab_source_generation()` (nonzero only when SOURCE is READY). Generation advances after a successful first Announce/Sync/Follow_Up and whenever SOURCE recovers from a TX/timestamp failure. The sparse SV publisher records this generation before its selected descriptor TX and checks it after TX. The portable evidence authority also compares it across successive 16-epoch samples and invalidates a comparison even when the seconds fields still appear continuous. This is an anti-false-positive guard, not a new claim about UTC/GNSS clock traceability. A zero SOURCE generation never qualifies phase measurements. diff --git a/docs/PTP_SV_PHASE_AUTHORITY_M2A.md b/docs/PTP_SV_PHASE_AUTHORITY_M2A.md new file mode 100644 index 000000000..7212f061b --- /dev/null +++ b/docs/PTP_SV_PHASE_AUTHORITY_M2A.md @@ -0,0 +1,55 @@ +# ESP32-P4 PTP ↔ SMV continuous timeline — M2A + +**Scope:** standards-oriented software phase steering and regression coverage, no relay available. This phase builds on M1 draft PR #153; the M2A PR targets the M1 branch. Do not merge to main independently. + +## Architecture, one timing authority + +- Hardware clock: ESP32-P4 Ethernet MAC IEEE1588 PTP time. LAB_SOURCE must already be active when the SV publisher starts. TIME_RECEIVER and MONITOR are NOT silently treated as a source clock for this M2A adapter. +- SV sample schedule: existing GPTimer 1 MHz rational-tick schedule and one canonical SV frame per period, retaining the verified firmware publisher/ISR; no second PTP stack and no duplicate scheduler. +- Off-realtime phase observer: one low-priority CPU0 task, notified at each publisher **sample-zero ISR**. It waits 250 ms then brackets one hardware PTP clock read with two GPTimer raw-timer readings. The timer lifetime is protected by a teardown mutex in the control plane, never an ISR mutex. +- Portable `SvPtpPublisherContinuityGate`: a new START session, missed canonical slot, or failed canonical Ethernet transmit revokes phase evidence immediately and forces a clean acquisition after faults cease; observer compares an atomic fault generation again before any command handoff. Healthy 4k/4.8k paths incur no extra fault-only atomic increments. +- Portable `SvPtpPhaseAuthority`: normalizes the phase of the GPTimer sample-zero *deadline* against the nearest PTP second; checks clock-read uncertainty, PTP monotonic continuity, epoch-generation continuity, gross steps and measured clock drift. On invalid evidence, reset/invalidate; no stale command persists. +- Bounded software PI: delivers at most ±400 ticks of 1 µs adjustment per one-second epoch. The ISR takes a lock-free correction command and distributes +/-1 tick over the **remaining intervals of the same epoch**, never resets `smpCnt`, catches up missed samples, or performs EMAC IOCTL/heap allocation/formatting/logging. A source/clock fault cancels a partially applied correction as soon as the background monitor detects it. +- Software telemetry continues to report measured scheduling lateness correctly by incorporating the accumulated ISR phase slews. +- Source PTP and canonical SV remain independently instrumented; AUTO `smpSynch` remains 0. **Software phase tracking is not proof of MAC egress timing or compliance.** + +## Safety and admission + +`CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=n` **by default**. An explicit opt-in source-mode CI build exercises the new adapter, while the normal public firmware keeps the earlier scheduler. Features run only if: +1. Hardware EMAC timestamp support is compiled in (ESP-IDF 5.x / ESP32-P4). +2. LAB_SOURCE was started and its PTP clock is available **before** SMV START. +3. Active profile uses one canonical ASDU per SV frame with `sample_counter_modulus == publisher_rate_hz` (4000/4800 fps family only; this alone does NOT assert IEC 61869-9 compatibility). +4. Phase observer task and timer mutex were successfully created. +5. Monotonic GMTimer/EMAC bracket and consecutive source epochs are validated. + +If any validation fails, no autonomous `smpSynch=1/2` claim is made. Do not enable this feature on energized power-system process buses. + +## Portable test acceptance + +`tests/test_sv_ptp_phase_authority.cpp` covers: + +- 480 consecutive epochs with simulated +80 ppm independent GPTimer/EMAC clocks at both 4000 and 4800 fps; no phase jumps, bounded steering and tracking convergence. +- Exact distribution of corrections ±400, ±81, ±1, 0, 1, 73, 400 over one second; zero drift of the original rational base schedule. +- Reject source jumps, missed epochs, stale/long bracket reads, invalid inputs and rearm without previous integral commands. +- Reject stale continuity evidence after STOP/START, publisher missed-slot batches and failed canonical frame transmissions; test that previous slew state is cleared before reacquisition. +- Relative GPTimer low-32-bit rollover after approximately 4295 seconds (71 minutes). +- Compile strict -Wall/-Wextra/-Wconversion/-Wsign-conversion/-Werror, GCC and Clang, plus MSVC /W4 /WX. ESP32-P4 SOURCE compile path exercised in dedicated CI matrix. + +## Remaining gates and known limitations + +1. **Physical measurement missing:** cross-clock PTP read uncertainty is modeled, but software tracking is based on the GPTimer alarm deadline. Actual SV sample instant / Ethernet MAC TX egress, contention, packetization latency, and long-term PTP output accuracy are NOT measured. +2. IEC/IEEE 61850-9-3 and IEC 61869-9 profile compliance must be independently assessed with full official profile text and a reference decoder/IED. The local clock class remains 248; an IED requiring a traceable precision GM may legitimately reject it. +3. A continuous full implementation must eventually account for PTP source quality, UTC/TAI provenance, startup/holdover/fault recovery, BMCA behavior, SCL-derived payload/ASDU variants, and physical TX timestamp evidence. No generic profile is assumed from an arbitrary SCL. +4. This lab-only feature is default OFF and does not certify absence of synchronization alarms. Release remains blocked until exact-head firmware CI and later physical benchmarks are reviewed. + +## Validation links + +- M1: https://github.com/masarray/arstack61850/pull/153 +- M2 tracking issue: https://github.com/masarray/arstack61850/issues/154 +- IEC/IEEE 61850-9-3: https://webstore.iec.ch/en/publication/24998 +- IEC 61850-9-2+AMD1: https://webstore.iec.ch/en/publication/66549 +- ESP-IDF GPTimer API: https://docs.espressif.com/projects/esp-idf/en/v5.5/esp32p4/api-reference/peripherals/gptimer.html + +### M2D restart-safe PTP source continuity + +The low-priority phase authority now takes a monotonic PTP SOURCE generation with each cross-clock sample. `SvPtpPublisherContinuityGate` explicitly treats a source recovery/restart as loss of phase evidence, clears the old PI integrator, and requires fresh clean epochs. Read and handoff checks compare the same SOURCE generation, including the STOP/START mutex window. A successful 1 Hz PTP Sync/Follow_Up cycle after previous source failure does NOT inherit historical SV phase qualification. This is default-off lab-only software evidence; it never asserts `smpSynch=1/2` or IED lock. diff --git a/docs/PTP_SV_RECEIVER_EPOCH_BINDING_M3C2.md b/docs/PTP_SV_RECEIVER_EPOCH_BINDING_M3C2.md new file mode 100644 index 000000000..cf4f84baf --- /dev/null +++ b/docs/PTP_SV_RECEIVER_EPOCH_BINDING_M3C2.md @@ -0,0 +1,32 @@ +# M3C.2 — External RECEIVER PTP-second ↔ SV smpCnt=0 association + +This extends the guarded experimental SV/PPS software-phase observer from +LAB_SOURCE to TIME_RECEIVER **only if** the canonical receiver lease is +usable (selected GM, measured discipline LOCKED, nonzero generation). +MONITOR and unlocked/holdover receiver states cannot arm the SV epoch. + +The *same* source-scoped lease (role, generation, selected GM clockIdentity +and port) is checked on both sides of the START-time EMAC PTP read, each +subsequent bracketed GPTimer/PTP observation, and control-plane handoff. +Source/GM switch, phase step, unlock/relock, stop, model restart or publisher +slot/TX discontinuity revokes any previously accepted PI steering. A new +external lease requires explicit SV STOP/START to re-arm from its PTP second. + +The independent `SvPtpPhaseAuthority` establishes the integer PTP second +of the last ISR-published smpCnt=0 deadline; a first observation is only a +baseline. Only consecutive epoch-generation/second continuity may arm the +existing bounded, at-most-one-tick ISR slew distributor. The callback never +reads PTP or locks; all IOCTL, source checks and computations remain in the +low-rate control-plane observer. RECEIVER arm requires the **experimental +phase-servo flag**, preserving existing nonexperimental receiver behavior. + +**Limits:** software bracket/epoch correlation is not external PPS GPIO +capture, timestamped wire egress or IEC/IEEE conformance evidence. Global +traceability is not inferred from mere clock lock. No path promotes AUTO +smpSynch: it stays fail-closed (0), until independent hardware timestamp +evidence and qualification are completed. + +Software gates: portable receiver lease/phase tests, previous regressions, +single-job guarded ESP32-P4 SOURCE/RECEIVER compile (no broad Draft +matrices). Still needed: silicon GM discipline, external PPS and MAC capture, +multi-minute drift/fault/recovery runs and release qualification. diff --git a/docs/PTP_SV_WIRE_AUDIT_M2B.md b/docs/PTP_SV_WIRE_AUDIT_M2B.md new file mode 100644 index 000000000..849dd0f46 --- /dev/null +++ b/docs/PTP_SV_WIRE_AUDIT_M2B.md @@ -0,0 +1,38 @@ +# PTP + SMV PCAP Wire Audit — M2B software evidence + +Implemented in `ariec61850_ptp_sv_wire_audit`, using the **existing** ARIEC61850 Ethernet/PTP/SV codecs, PCAP reader and SampledValuesStreamSupervisor. The new `PcapReader::for_each_packet` API reads **one packet at a time** with bounded memory; legacy `read_all()` is preserved using the same parser. No second protocol parser and no embedded realtime changes. + +## Purpose + +A combined packet-level audit to discover software-wire defects before an IED is available. It checks the engineered stream identity (APPID + svID), source MAC consistency, per-ASDU `smpCnt` continuity/wrap/gap/duplicate/out-of-order, `confRev` changes and counts of `smpSynch` 0/1/2, alongside PTP Announce/Sync/Follow_Up/PdelayResp visibility, selected domain and unexpected source changes. + +Use a classic Ethernet **.pcap** file. If Wireshark captures .pcapng, export/save as .pcap first (the existing ARIEC61850 PcapReader is a classic-PCAP reader). Example after host C++ build: + +```sh +./build/ariec61850_ptp_sv_wire_audit capture.pcap --sv-id 'MU01/LLN0$SV1' --app-id 0x4000 --counter-modulus 4000 --ptp-domain 0 --json +``` + +`--counter-modulus` is required, not inferred from frame rate: one SV frame may contain multiple ASDUs and particular IEC 61869-9 profiles have distinct counter policies. Different APPID, svID or source MAC streams are never combined into one counter history. + +## Tests and acceptance + +`ariec61850_ptp_sv_wire_audit --self-test` writes and reads an in-memory PCAP using the existing PTP and SV encoders. It verifies both a CLEAN combined capture and a NEGATIVE capture. It checks Announce/Sync/Follow_Up, wrap 3→0 at an explicit modulus 4, ignores an interleaved unrelated svID, and detects an intentional missing-sample counter gap. This is a small software regression, not a conformance certificate. + +- `svWireConsistent` means **only** the selected captured SV packets have no observed counter and identity errors. It does not infer correct profiles or absence of loss outside the capture window. +- `ptpPacketsVisible` means Announce/Sync and FollowUp for observed two-step traffic are visible in the selected domain. A passive PCAP **cannot attest PTP lock or GM accuracy**. +- `counterZeroHostCapturePhaseMinUs/MaxUs` are derived from **PCAP host receive timestamps**; do not interpret them as MAC hardware TX phase or legitimate microsecond precision. +- `ptpClockSynchronized`, `svPtpPhaseQualified`, and `iedAlarmFree` are `null` deliberately. Only a reference clock, hardware egress evidence and actual IED acceptance can resolve them. +- Exit status 0 means both wire consistency and essential PTP packet visibility were observed. It **never** means IEC/IEEE 61850-9-3 certification. + +## Remaining PTP/SV engineering gates + +1. Instrument actual ESP32-P4 canonical SV TX time against EMAC PTP (hardware timestamps / independent analyzer) without adding per-packet IOCTL latency. +2. Validate active selected SCL profile and IEEE/IEC profile requirements, including ASDU grouping and sample counter epoch. +3. Test sustained 4k/4.8k continuous phase steering and fault recovery on real silicon; keep the M2A experimental adapter default OFF until then. +4. Save PCAP + firmware commit + IED configuration for multi-vendor relay interoperability when equipment is available. + +This tool is offline host-only. It does not affect 4000-fps embedded ISR behavior, native Qt Studio or public installer builds. + +## Large-capture optimization + +Unlike `read_all()`, the audit command consumes packets through `PcapReader::for_each_packet` and does **not** retain all decoded frames. The parser still bounds individual frames by the validated PCAP snap length (maximum supported 16 MiB). This permits hour-long 4,000/4,800-fps captures without O(packet count) memory growth; disk I/O and decode CPU time still scale with the capture. The in-memory self-test compares streaming and bulk-reader behavior on the same positive/negative fixture. diff --git a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild index 1613a616e..dcfffa482 100644 --- a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild +++ b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild @@ -55,6 +55,34 @@ config AR_PTP_LAB_TX role 2 is passive monitor-only. This feature is not a GPS-backed or certified grandmaster claim. +config AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL + bool "LAB ONLY: continuous bounded PTP-to-SV phase steering (experimental)" + default n + depends on AR_PTP_LAB_TX && AR_SMV_P2_REALTIME_50HZ + help + Opt-in experimental EMAC-PTP / GPTimer phase bridge. Reads hardware + clocks in a low-priority task once per SV second, then distributes + at most 400 +/-1us corrections across remaining SV deadlines. + Requires SOURCE active BEFORE SV START and exactly one transmitted + ASDU per sample with publisher fps matching smpCnt modulus. + AUTO smpSynch remains unsynchronized until independent wire/hardware + timestamp evidence is available. DO NOT use on a live substation. + +config AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL + bool "LAB ONLY: sparse canonical SV EMAC hardware TX timestamp evidence" + default n + depends on AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL && !AR_SMV_BROADCAST_MIRROR && !AR_SMV_DIAGNOSTIC_PROBES + help + Diagnostic only: once per 16 canonical smpCnt-zero epochs, use the + ESP32-P4 MAC extended TX API to request a descriptor timestamp on + the actual SV frame, while all other frames use the regular TX path. + The ESP-IDF 5.x driver busy-waits for the selected descriptor, which + can perturb realtime scheduling; never enable on a live process bus. + Disable the broadcast mirror and diagnostic probes to avoid extra + competing TX traffic. Missing/non-SV timestamps are recorded as + unavailable, never substituted with esp_timer time or claimed as + proof that a relay has synchronized. Defaults OFF. + config AR_PTP_OPERATING_ROLE int "PTP operating role (0=lab source, 1=time receiver, 2=monitor)" range 0 2 @@ -65,6 +93,17 @@ config AR_PTP_OPERATING_ROLE listens to an external PTP source, performs hardware-timestamped peer delay, and disciplines the ESP32-P4 IEEE1588 clock. MONITOR is passive. +config AR_PTP_ALLOW_NONSTANDARD_LAB_CADENCE + bool "LAB ONLY: allow non-IEC 61850-9-3 SOURCE cadence" + default n + depends on AR_PTP_LAB_TX + help + Default SOURCE admission enforces nominal 1-second Announce and Sync + and an enabled hardware peer-delay responder, as expected by the + IEC/IEEE 61850-9-3 power utility profile. Turn this on ONLY for + intentional isolated nonstandard packet tests; it does not make + the LAB_SOURCE a traceable certified PTP Grandmaster. + config AR_PTP_DOMAIN int "PTP domain number" range 0 255 @@ -120,7 +159,7 @@ config AR_PTP_ANNOUNCE_INTERVAL_MS config AR_PTP_SYNC_INTERVAL_MS int "PTP Sync interval (ms)" range 20 5000 - default 250 + default 1000 depends on AR_PTP_LAB_TX help LAB_SOURCE uses two-step Sync/Follow_Up so Follow_Up can carry the exact @@ -190,6 +229,21 @@ config AR_PTP_CURRENT_UTC_OFFSET default 37 depends on AR_PTP_LAB_TX +config AR_PTP_MANUAL_UTC_OFFSET_VALID + bool "UTC offset manually verified (Announce currentUtcOffsetValid)" + default n + depends on AR_PTP_LAB_TX + help + Enable only after the operator verifies the configured TAI-UTC offset + against an authoritative leap-second record AND verifies that the + RTC-derived UTC epoch is meaningfully correct. Without this, SOURCE + advertises neither the PTP-timescale nor offset-valid flag even if + the hardware clock could be seeded. This remains an INTERNAL + OSCILLATOR and does NOT imply UTC traceability, frequency lock, + microsecond-grade accuracy, or a certified Grandmaster. + The runtime also requires a plausible system UTC epoch before it + advertises currentUtcOffsetValid. + config AR_PTP_MAX_PATH_DELAY_NS int "TIME_RECEIVER maximum accepted peer path delay (ns)" range 1000 100000000 @@ -229,7 +283,7 @@ config AR_PTP_LOCK_REQUIRED_SAMPLES config AR_PTP_SYNC_TIMEOUT_MS int "TIME_RECEIVER Sync timeout before HOLDOVER/UNLOCKED (ms)" range 100 10000 - default 1000 + default 3000 depends on AR_PTP_LAB_TX config AR_PTP_HOLDOVER_TIMEOUT_MS diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 8adfa3fbb..955d7ca75 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -5,12 +5,39 @@ #include "ariec61850/sampled_values/frame.hpp" #include "ariec61850/sampled_values/frame_codec.hpp" #include "ariec61850/sampled_values/live_signal_state.hpp" +#include "ariec61850/time_sync/sv_ptp_epoch.hpp" +#include "ariec61850/time_sync/sv_ptp_clock_lease.hpp" #include "driver/gptimer.h" #include "ethernet_port.h" #include "live_control.hpp" #include "runtime_profile.hpp" +#include "sdkconfig.h" +#if CONFIG_AR_PTP_LAB_TX +#include "ptp_lab_task.hpp" +#include "esp_idf_version.h" +#include "soc/soc_caps.h" +#if ESP_IDF_VERSION_MAJOR < 6 +#include "esp_eth_mac_esp.h" +#endif +#endif #include "smp_synch_lab.hpp" +#if defined(CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL) && CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL && \ + CONFIG_AR_PTP_LAB_TX && defined(SOC_EMAC_IEEE1588V2_SUPPORTED) && \ + SOC_EMAC_IEEE1588V2_SUPPORTED && ESP_IDF_VERSION_MAJOR < 6 +#define AR_SV_PTP_PHASE_SUPPORTED 1 +#include "ariec61850/time_sync/sv_ptp_phase_authority.hpp" +#include "freertos/semphr.h" +#else +#define AR_SV_PTP_PHASE_SUPPORTED 0 +#endif +#if AR_SV_PTP_PHASE_SUPPORTED && defined(CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL) && \ + CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL +#define AR_SV_TX_EGRESS_SUPPORTED 1 +#include "ariec61850/time_sync/sv_tx_egress_evidence.hpp" +#else +#define AR_SV_TX_EGRESS_SUPPORTED 0 +#endif #include "esp_err.h" #include "esp_eth.h" #include "esp_event.h" @@ -19,7 +46,6 @@ #include "freertos/FreeRTOS.h" #include "freertos/event_groups.h" #include "freertos/task.h" -#include "sdkconfig.h" #include #include @@ -27,6 +53,7 @@ #include #include #include +#include #include #include #include @@ -44,6 +71,8 @@ using ar::esp32p4::smv::runtime_profile_initialize; using ar::esp32p4::smv::runtime_profile_snapshot; using ar::esp32p4::smv::runtime_profile_validate; using ar::esp32p4::smv::smp_synch_lab_decision; +using ar::esp32p4::smv::smp_synch_set_source_local_qualified; +using ar::esp32p4::smv::smp_synch_source_local_qualified; using ar::esp32p4::smv::take_start_request; using ar::iec61850::ethernet::MacAddress; using ar::iec61850::ethernet::VlanTag; @@ -54,6 +83,26 @@ using ar::iec61850::sampled_values::SampledValuesFrameCodec; using ar::iec61850::sampled_values::SampledValuesPdu; using ar::iec61850::sampled_values::SvFixedPointSineEngine; using ar::iec61850::sampled_values::SvLiveSignalState; +using ar::iec61850::time_sync::SvSecondBoundaryArm; +using ar::iec61850::time_sync::SvPtpClockObservation; +using ar::iec61850::time_sync::plan_sv_ptp_second_arm; +using ar::iec61850::time_sync::rebase_sv_ptp_second_arm; +using ar::iec61850::time_sync::SvPtpClockLease; +using ar::iec61850::time_sync::PtpAuthorityRole; +using ar::iec61850::time_sync::same_sv_ptp_clock_lease; +using ar::iec61850::time_sync::valid_sv_ptp_clock_lease; +#if AR_SV_PTP_PHASE_SUPPORTED +using ar::iec61850::time_sync::SvBoundedSlewCursor; +using ar::iec61850::time_sync::SvPtpPhaseAuthority; +using ar::iec61850::time_sync::SvPtpPhaseObservation; +using ar::iec61850::time_sync::SvPtpPublisherContinuityGate; +#endif +#if AR_SV_TX_EGRESS_SUPPORTED +using ar::iec61850::time_sync::SvTxEgressEvidence; +using ar::iec61850::time_sync::SvLocalSyncEvidenceGate; +using ar::iec61850::time_sync::SvTxEgressObservation; +using ar::iec61850::time_sync::SvTxEgressSummary; +#endif constexpr char kTag[] = "ar_smv_p3"; constexpr EventBits_t kLinkUpBit = BIT0; @@ -84,7 +133,23 @@ struct TimingStats final { TimingStats g_interval_stats{}; static_assert(std::atomic::is_always_lock_free); +#if AR_SV_PTP_PHASE_SUPPORTED +static_assert(std::atomic::is_always_lock_free); +static_assert(std::atomic::is_always_lock_free); +#endif std::atomic g_sample_tick_total{0U}; +#if AR_SV_TX_EGRESS_SUPPORTED +constexpr std::uint32_t kEgressEvidenceEveryZeroEpochs = 16U; +portMUX_TYPE g_tx_egress_mux = portMUX_INITIALIZER_UNLOCKED; +SvTxEgressEvidence g_tx_egress_evidence{}; +SvLocalSyncEvidenceGate g_local_sync_evidence{}; // same critical section as egress tracker +#endif +#if AR_SV_PTP_PHASE_SUPPORTED +SemaphoreHandle_t g_phase_timer_mutex = nullptr; +gptimer_handle_t g_monitored_sample_clock = nullptr; // under mutex, never in ISR +TaskHandle_t g_phase_observer_task = nullptr; +std::atomic g_phase_session_serial{0U}; +#endif struct PacketTemplate final { std::vector bytes; @@ -123,6 +188,23 @@ struct SampleClockContext final { TaskHandle_t publisher_task{}; RationalScheduleCursor schedule{}; gptimer_alarm_config_t next_alarm{}; +#if AR_SV_PTP_PHASE_SUPPORTED + // ISR-owned epoch count/deadline are published with release/acquire. + // Only the low 32 bits are used (71-minute wrap at 1 MHz). + std::atomic epoch_generation{0U}; + std::atomic session_generation{0U}; + std::atomic last_epoch_alarm{0U}; + std::atomic requested_slew_ticks{0}; + std::atomic cumulative_slew_ticks{0}; + std::atomic publisher_fault_generation{0U}; + std::atomic phase_authority_valid{false}; + // Immutable for one monitored timer session; read under phase mutex. + SvPtpClockLease armed_ptp_clock{}; + SvBoundedSlewCursor phase_slew{}; + std::uint32_t phase_index{}; + bool previous_phase_healthy{}; + bool phase_enabled{}; // set before timer START; only ISR reads it +#endif }; SampleClockContext g_sample_clock_context{}; @@ -359,7 +441,63 @@ bool sample_alarm_callback( const gptimer_alarm_event_data_t* event, void* user_context) { auto* context = static_cast(user_context); - const auto interval = context->schedule.next_ticks(); + std::int32_t adjustment = 0; +#if AR_SV_PTP_PHASE_SUPPORTED + const std::uint32_t index = context->phase_index; + bool epoch_boundary = false; + if (context->phase_enabled) { + if (index == 0U) { + epoch_boundary = true; + context->last_epoch_alarm.store( + static_cast(event->alarm_value), + std::memory_order_relaxed); + context->epoch_generation.fetch_add(1U, std::memory_order_release); + context->phase_slew.arm(context->schedule.rate_hz, 0); + } + context->phase_index = index + 1U >= context->schedule.rate_hz + ? 0U : index + 1U; + // Commands arrive after a cross-clock read from the background task. + // Apply within THIS epoch, across all remaining intervals. Never + // change smpCnt or issue multiple timer alarms to catch up. + const bool phase_valid = context->phase_authority_valid.load( + std::memory_order_relaxed); + if (!phase_valid) { + // Cancel once on a healthy->fault transition; do not reset + // distributor memory at the full 4k/4.8k ISR cadence. + if (context->previous_phase_healthy) { + context->phase_slew.arm(context->schedule.rate_hz, 0); + context->previous_phase_healthy = false; + } + } else { + context->previous_phase_healthy = true; + // Most frames have no new command. Avoid a locked RMW atomic + // exchange on all 4k/4.8k interrupts; take it only when nonzero. + if (context->requested_slew_ticks.load( + std::memory_order_relaxed) != 0) { + const std::int32_t requested = + context->requested_slew_ticks.exchange( + 0, std::memory_order_relaxed); + if (requested != 0 && + index + 16U < context->schedule.rate_hz) { + context->phase_slew.arm( + context->schedule.rate_hz - index, requested); + } + } + } + adjustment = context->phase_slew.next_adjustment(); + if (adjustment != 0) { + context->cumulative_slew_ticks.fetch_add( + adjustment, std::memory_order_relaxed); + } + } +#endif + std::uint32_t interval = context->schedule.next_ticks(); +#if AR_SV_PTP_PHASE_SUPPORTED + // Exactly the original ISR path in normal production builds. The + // experimental opt-in only adjusts a positive deadline by one tick. + if (adjustment > 0) ++interval; + else if (adjustment < 0 && interval > 1U) --interval; +#endif context->next_alarm = {}; context->next_alarm.alarm_count = event->alarm_value + interval; context->next_alarm.flags.auto_reload_on_alarm = false; @@ -367,13 +505,99 @@ bool sample_alarm_callback( BaseType_t high_priority_task_woken = pdFALSE; vTaskNotifyGiveFromISR(context->publisher_task, &high_priority_task_woken); +#if AR_SV_PTP_PHASE_SUPPORTED + if (epoch_boundary && g_phase_observer_task != nullptr) { + vTaskNotifyGiveFromISR(g_phase_observer_task, &high_priority_task_woken); + } +#endif return high_priority_task_woken == pdTRUE; } +// One status-derived authority snapshot per read: never assemble role, +// generation and GM identity from separate getters. +[[nodiscard]] SvPtpClockLease read_ptp_epoch_clock_lease() noexcept { + SvPtpClockLease lease{}; +#if CONFIG_AR_PTP_LAB_TX + ar_ptp_timing_authority_t status{}; + if (ar_ptp_lab_get_timing_authority(&status)) { + lease.role = static_cast(status.role); + lease.generation = status.generation; + lease.clock_usable = status.clock_usable; + lease.source_selected = status.source_selected; + std::copy_n(status.selected_source_clock_identity, 8U, + lease.source_clock_identity.begin()); + lease.source_port = status.selected_source_port_number; + } +#endif + return lease; +} + +struct SvPtpEpochArm final { + SvSecondBoundaryArm timer{}; + SvPtpClockLease lease{}; + std::uint64_t clock_read_after_us{}; +}; + +// Align the initial sample with EMAC PTP and retain EXACT authority identity. +// The receiver path is experimental-only; normal builds stay free-running. +[[nodiscard]] std::optional +plan_ptp_epoch_arm(const esp_eth_handle_t eth_handle) noexcept { +#if CONFIG_AR_PTP_LAB_TX && defined(SOC_EMAC_IEEE1588V2_SUPPORTED) && \ + SOC_EMAC_IEEE1588V2_SUPPORTED && ESP_IDF_VERSION_MAJOR < 6 + const auto lease = read_ptp_epoch_clock_lease(); + if (!valid_sv_ptp_clock_lease(lease)) return std::nullopt; + if (lease.role == PtpAuthorityRole::receiver && + !AR_SV_PTP_PHASE_SUPPORTED) return std::nullopt; + eth_mac_time_t ptp_time{}; + const std::int64_t before_us = esp_timer_get_time(); + const auto result = esp_eth_ioctl( + eth_handle, static_cast(ETH_MAC_ESP_CMD_G_PTP_TIME), + &ptp_time); + const std::int64_t after_us = esp_timer_get_time(); + if (result != ESP_OK || before_us < 0 || after_us < before_us || + (ptp_time.seconds == 0U && ptp_time.nanoseconds == 0U) || + !same_sv_ptp_clock_lease(lease, read_ptp_epoch_clock_lease())) { + return std::nullopt; + } + const auto arm = plan_sv_ptp_second_arm(SvPtpClockObservation{ + ptp_time.nanoseconds, static_cast(before_us), + static_cast(after_us)}); + if (!arm.has_value()) return std::nullopt; + return SvPtpEpochArm{*arm, lease, static_cast(after_us)}; +#else + (void)eth_handle; + return std::nullopt; +#endif +} + gptimer_handle_t start_sample_clock( TaskHandle_t publisher_task_handle, - const std::uint32_t publisher_rate_hz) { + const std::uint32_t publisher_rate_hz, + const esp_eth_handle_t eth_handle, + bool& epoch_armed, + const bool profile_has_second_aligned_counter) { + epoch_armed = false; +#if AR_SV_PTP_PHASE_SUPPORTED + g_sample_clock_context.publisher_task = publisher_task_handle; + g_sample_clock_context.next_alarm = {}; + g_sample_clock_context.epoch_generation.store(0U, std::memory_order_relaxed); + g_sample_clock_context.session_generation.store( + g_phase_session_serial.fetch_add(1U, std::memory_order_relaxed) + 1U, + std::memory_order_release); + g_sample_clock_context.last_epoch_alarm.store(0U, std::memory_order_relaxed); + g_sample_clock_context.requested_slew_ticks.store(0, std::memory_order_relaxed); + g_sample_clock_context.cumulative_slew_ticks.store(0, std::memory_order_relaxed); + g_sample_clock_context.publisher_fault_generation.store(0U, std::memory_order_relaxed); + g_sample_clock_context.phase_authority_valid.store(false, std::memory_order_relaxed); + g_sample_clock_context.armed_ptp_clock = {}; + g_sample_clock_context.phase_index = 0U; + g_sample_clock_context.previous_phase_healthy = false; + g_sample_clock_context.phase_enabled = false; + g_sample_clock_context.phase_slew.arm(publisher_rate_hz, 0); +#else g_sample_clock_context = {}; + (void)profile_has_second_aligned_counter; +#endif g_sample_clock_context.publisher_task = publisher_task_handle; g_sample_clock_context.schedule.reset(publisher_rate_hz); @@ -390,22 +614,71 @@ gptimer_handle_t start_sample_clock( ESP_ERROR_CHECK(gptimer_register_event_callbacks( timer, &callbacks, &g_sample_clock_context)); + // The timer is configured before reading EMAC PTP time, minimizing + // cross-clock arm latency without touching the high-frequency callback. + const auto epoch_arm = plan_ptp_epoch_arm(eth_handle); + // Time spent configuring GPTimer after the EMAC PTP read must not + // shift the first smpCnt=0 deadline. Recheck the same lease before arm. + const std::int64_t pre_arm_us = esp_timer_get_time(); + const auto corrected_arm = epoch_arm.has_value() && pre_arm_us >= 0 + ? rebase_sv_ptp_second_arm( + epoch_arm->timer, epoch_arm->clock_read_after_us, + static_cast(pre_arm_us)) + : std::nullopt; + epoch_armed = corrected_arm.has_value() && + same_sv_ptp_clock_lease( + epoch_arm->lease, read_ptp_epoch_clock_lease()); +#if AR_SV_PTP_PHASE_SUPPORTED + if (epoch_armed) { + g_sample_clock_context.armed_ptp_clock = epoch_arm->lease; + } + g_sample_clock_context.phase_enabled = + epoch_armed && profile_has_second_aligned_counter && + g_phase_observer_task != nullptr && g_phase_timer_mutex != nullptr; +#endif g_sample_clock_context.next_alarm = {}; - g_sample_clock_context.next_alarm.alarm_count = - g_sample_clock_context.schedule.next_ticks(); + g_sample_clock_context.next_alarm.alarm_count = epoch_armed + ? corrected_arm->first_alarm_us + : g_sample_clock_context.schedule.next_ticks(); g_sample_clock_context.next_alarm.flags.auto_reload_on_alarm = false; ESP_ERROR_CHECK(gptimer_set_alarm_action(timer, &g_sample_clock_context.next_alarm)); ESP_ERROR_CHECK(gptimer_enable(timer)); ESP_ERROR_CHECK(gptimer_start(timer)); +#if AR_SV_PTP_PHASE_SUPPORTED + if (g_sample_clock_context.phase_enabled) { + xSemaphoreTake(g_phase_timer_mutex, portMAX_DELAY); + g_monitored_sample_clock = timer; + xSemaphoreGive(g_phase_timer_mutex); + } +#endif return timer; } void stop_sample_clock(gptimer_handle_t& timer) noexcept { + // Release the wire claim before teardown; old MAC evidence never + // survives STOP/START, even when the same SOURCE later restarts. + smp_synch_set_source_local_qualified(false); if (timer == nullptr) return; +#if AR_SV_PTP_PHASE_SUPPORTED + // Serialize teardown with the non-ISR phase observer, so a GPTimer + // handle can never be deleted during its bracketed EMAC PTP read. + xSemaphoreTake(g_phase_timer_mutex, portMAX_DELAY); + if (g_monitored_sample_clock == timer) g_monitored_sample_clock = nullptr; + g_sample_clock_context.session_generation.store( + 0U, std::memory_order_release); + g_sample_clock_context.requested_slew_ticks.store( + 0, std::memory_order_relaxed); + g_sample_clock_context.phase_authority_valid.store( + false, std::memory_order_release); + g_sample_clock_context.armed_ptp_clock = {}; +#endif static_cast(gptimer_stop(timer)); static_cast(gptimer_disable(timer)); static_cast(gptimer_del_timer(timer)); timer = nullptr; +#if AR_SV_PTP_PHASE_SUPPORTED + xSemaphoreGive(g_phase_timer_mutex); +#endif } void publisher_task(void* argument) { @@ -443,7 +716,14 @@ void publisher_task(void* argument) { std::uint16_t mirror_sample_count = 0U; TimingStats local{}; bool schedule_anchored = false; + bool epoch_armed = false; std::int64_t expected_wake_us = 0; +#if AR_SV_TX_EGRESS_SUPPORTED + std::uint32_t zero_epoch_frames_seen = 0U; +#endif +#if AR_SV_PTP_PHASE_SUPPORTED + std::int32_t previous_slew_total = 0; +#endif std::uint32_t consecutive_canonical_tx_failures = 0U; esp_err_t last_canonical_tx_error = ESP_OK; @@ -486,7 +766,16 @@ void publisher_task(void* argument) { consecutive_canonical_tx_failures = 0U; last_canonical_tx_error = ESP_OK; expected_schedule.reset(active_profile.publisher_rate_hz); - static_cast(expected_schedule.next_ticks()); // first alarm interval +#if AR_SV_TX_EGRESS_SUPPORTED + zero_epoch_frames_seen = 0U; + portENTER_CRITICAL(&g_tx_egress_mux); + g_local_sync_evidence.reset(); + g_tx_egress_evidence.reset(); + portEXIT_CRITICAL(&g_tx_egress_mux); +#endif +#if AR_SV_PTP_PHASE_SUPPORTED + previous_slew_total = 0; +#endif ESP_LOGI(kTag, "PROFILE armed generation=%llu svID=%s APPID=0x%04X rate=%lu wrap=%u confRev=%lu VLAN=%u/%u/%u frameLen=%u", static_cast(active_profile.generation), @@ -504,7 +793,23 @@ void publisher_task(void* argument) { // Formatting the profile log after timer enable previously queued // dozens of ISR notifications before the publisher could consume // its first tick, so every START falsely began with missed slots. - sample_clock = start_sample_clock(task_handle, active_profile.publisher_rate_hz); + sample_clock = start_sample_clock( + task_handle, active_profile.publisher_rate_hz, eth_handle, + epoch_armed, + // The 1-ASDU-per-frame 80-SPC profile has exactly one + // sample-counter epoch per publisher second. Other SCL + // profiles need an explicit standard-derived epoch policy. + active_profile.sample_counter_modulus == + active_profile.publisher_rate_hz); + if (!epoch_armed) { + // Legacy first alarm already consumes the first rational tick; + // a PTP-armed first alarm is an independent epoch delay. + static_cast(expected_schedule.next_ticks()); + } + // Do not log after the timer is enabled: even a single slow + // console write can accumulate ISR notifications before the + // publisher consumes its first slot (false START misses). + // This does not change fail-closed smpSynch AUTO=0. continue; } @@ -526,10 +831,33 @@ void publisher_task(void* argument) { elapsed_ticks += expected_schedule.next_ticks(); } expected_wake_us += static_cast(elapsed_ticks); +#if AR_SV_PTP_PHASE_SUPPORTED + // Preserve the existing lateness metric when the ISR slews its + // timer deadlines. Otherwise planned corrective ticks would be + // misreported as publisher latency/drift. + const std::int32_t latest_slew_total = + g_sample_clock_context.cumulative_slew_ticks.load( + std::memory_order_relaxed); + expected_wake_us += + ar::iec61850::time_sync::sv_phase_detail::signed_delta_u32( + static_cast(latest_slew_total), + static_cast(previous_slew_total)); + previous_slew_total = latest_slew_total; +#endif } const auto signal_state = live_signal_snapshot(); if (pending_ticks > 1U) { +#if AR_SV_PTP_PHASE_SUPPORTED + // A skipped frame breaks canonical timeline evidence, even if + // GPTimer deadlines were mathematically aligned. Fault-only + // atomic operations; the normal 4k/4.8kHz path is untouched. + g_sample_clock_context.publisher_fault_generation.fetch_add( + 1U, std::memory_order_release); + g_sample_clock_context.phase_authority_valid.store( + false, std::memory_order_release); + smp_synch_set_source_local_qualified(false); +#endif missed_slots = static_cast(pending_ticks - 1U); canonical_sample_count = static_cast( (static_cast(canonical_sample_count) + missed_slots) % @@ -550,17 +878,116 @@ void publisher_task(void* argument) { ++local.canonical_fail; last_canonical_tx_error = ESP_ERR_INVALID_STATE; ++consecutive_canonical_tx_failures; +#if AR_SV_PTP_PHASE_SUPPORTED + g_sample_clock_context.publisher_fault_generation.fetch_add( + 1U, std::memory_order_release); + g_sample_clock_context.phase_authority_valid.store( + false, std::memory_order_release); + smp_synch_set_source_local_qualified(false); +#endif #if CONFIG_AR_SMV_BROADCAST_MIRROR ++local.mirror_fail; #endif } else { +#if AR_SV_PTP_PHASE_SUPPORTED + // After qualification, consult the lightweight SOURCE freshness + // authority on each frame; a stopped/faulted PTP source must not + // continue advertising previously measured local synchronization. + if (smp_synch_source_local_qualified() && + (!g_sample_clock_context.phase_authority_valid.load( + std::memory_order_acquire) || + ar_ptp_lab_source_generation() != + g_sample_clock_context.armed_ptp_clock.generation)) { + smp_synch_set_source_local_qualified(false); + } +#endif const auto sync_decision = smp_synch_lab_decision(); const auto sample_synchronization = static_cast(sync_decision.value); patch_packet( canonical, canonical_sample_count, sample_synchronization, row, signal_state); +#if AR_SV_TX_EGRESS_SUPPORTED + // ONCE / 16 seconds, replace normal SV TX for the real canonical + // frame with descriptor-stamped TX. ESP-IDF 5.5.4 polls the DMA + // descriptor for its timestamp: never run this on every frame. + // This is diagnostic evidence, not validated IED synchronization. + bool take_egress_timestamp = false; + SvTxEgressObservation egress{}; + std::uint32_t evidence_session = 0U; + std::uint32_t fault_generation_before = 0U; + if (epoch_armed && g_sample_clock_context.phase_enabled && + canonical_sample_count == 0U && missed_slots == 0U && + active_profile.sample_counter_modulus == + active_profile.publisher_rate_hz) { + ++zero_epoch_frames_seen; + if ((zero_epoch_frames_seen % kEgressEvidenceEveryZeroEpochs) == 0U && + ar_ptp_lab_is_running()) { + take_egress_timestamp = true; + evidence_session = g_sample_clock_context.session_generation.load( + std::memory_order_acquire); + egress.sample_epoch_generation = + g_sample_clock_context.epoch_generation.load( + std::memory_order_acquire); + egress.publisher_session_generation = evidence_session; + egress.ptp_source_generation = ar_ptp_lab_source_generation(); + egress.scheduled_alarm_ticks_low32 = + g_sample_clock_context.last_epoch_alarm.load( + std::memory_order_acquire); + fault_generation_before = + g_sample_clock_context.publisher_fault_generation.load( + std::memory_order_acquire); + egress.publisher_fault_generation = fault_generation_before; + egress.smp_cnt = canonical_sample_count; + egress.no_missed_slots = pending_ticks == 1U; + } + } + eth_mac_time_t egress_hw_time{}; + const std::int64_t submit_before_us = + take_egress_timestamp ? esp_timer_get_time() : 0; + const esp_err_t canonical_result = take_egress_timestamp + ? esp_eth_transmit_ctrl_vargs( + eth_handle, &egress_hw_time, 2U, canonical.bytes.data(), + static_cast(canonical.bytes.size())) + : esp_eth_transmit( + eth_handle, canonical.bytes.data(), canonical.bytes.size()); + if (take_egress_timestamp) { + const std::int64_t submit_after_us = esp_timer_get_time(); + const std::int64_t duration = + submit_after_us >= submit_before_us + ? submit_after_us - submit_before_us : 0LL; + egress.submit_duration_us = static_cast( + std::min(duration, 0xFFFFFFFFLL)); + // A descriptor polling delay that exceeds one scheduled + // sample interval is diagnostic interference: retain the TX + // timestamp but mark the sample's timing evidence unqualified. + egress.no_missed_slots = egress.no_missed_slots && + egress.submit_duration_us <= + kTimerResolutionHz / active_profile.publisher_rate_hz; + egress.canonical_tx_success = canonical_result == ESP_OK; + egress.hw_ptp_seconds = egress_hw_time.seconds; + egress.hw_ptp_nanoseconds = egress_hw_time.nanoseconds; + egress.same_sample_epoch = egress.ptp_source_generation != 0U && + ar_ptp_lab_source_generation() == + egress.ptp_source_generation && + evidence_session != 0U && + g_sample_clock_context.session_generation.load( + std::memory_order_acquire) == evidence_session && + g_sample_clock_context.epoch_generation.load( + std::memory_order_acquire) == egress.sample_epoch_generation && + g_sample_clock_context.publisher_fault_generation.load( + std::memory_order_acquire) == fault_generation_before; + portENTER_CRITICAL(&g_tx_egress_mux); + const auto result = g_tx_egress_evidence.record(egress); + const auto phase_ns = g_tx_egress_evidence.summary().last_phase_ns; + g_local_sync_evidence.observe(egress, result, phase_ns); + portEXIT_CRITICAL(&g_tx_egress_mux); + if (result != ar::iec61850::time_sync::SvTxEvidenceResult::measured) + smp_synch_set_source_local_qualified(false); + } +#else const esp_err_t canonical_result = esp_eth_transmit(eth_handle, canonical.bytes.data(), canonical.bytes.size()); +#endif if (canonical_result == ESP_OK) { ++local.canonical_ok; consecutive_canonical_tx_failures = 0U; @@ -569,6 +996,13 @@ void publisher_task(void* argument) { ++local.canonical_fail; last_canonical_tx_error = canonical_result; ++consecutive_canonical_tx_failures; +#if AR_SV_PTP_PHASE_SUPPORTED + g_sample_clock_context.publisher_fault_generation.fetch_add( + 1U, std::memory_order_release); + g_sample_clock_context.phase_authority_valid.store( + false, std::memory_order_release); + smp_synch_set_source_local_qualified(false); +#endif } #if CONFIG_AR_SMV_BROADCAST_MIRROR @@ -617,11 +1051,208 @@ void publisher_task(void* argument) { #endif } +#if AR_SV_PTP_PHASE_SUPPORTED +// Revoke only the matching GPTimer generation; a stale monitor must never +// overwrite the new session's phase gate following STOP/START. +void revoke_sv_phase_session(const std::uint32_t expected_session) noexcept { + xSemaphoreTake(g_phase_timer_mutex, portMAX_DELAY); + if (expected_session != 0U && + g_sample_clock_context.session_generation.load( + std::memory_order_acquire) == expected_session) { + g_sample_clock_context.phase_authority_valid.store( + false, std::memory_order_release); + g_sample_clock_context.requested_slew_ticks.store( + 0, std::memory_order_relaxed); + smp_synch_set_source_local_qualified(false); + } + xSemaphoreGive(g_phase_timer_mutex); +} + +// Shadow-verified software phase servo. This task never controls smpSynch, +// executes no ISR code and acquires a GPTimer handle under a teardown mutex. +// It samples clocks once per sample-counter epoch, 250ms after smpCnt=0. +void sv_ptp_phase_observer_task(void* argument) { + const auto eth_handle = static_cast(argument); + SvPtpPhaseAuthority controller; + SvPtpPublisherContinuityGate continuity; + std::uint32_t accepted_epochs = 0U; + std::uint32_t controller_session = 0U; + while (true) { + const std::uint32_t events = ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + const std::uint32_t notified_session = + g_sample_clock_context.session_generation.load( + std::memory_order_acquire); + if (controller_session != notified_session) { + controller.reset(); + continuity.reset(); + controller_session = notified_session; + } + vTaskDelay(pdMS_TO_TICKS(250)); + const std::uint32_t current_faults = + g_sample_clock_context.publisher_fault_generation.load( + std::memory_order_acquire); + const auto lease = read_ptp_epoch_clock_lease(); + // A role/GM/relock change revokes the previous PI integrator. + if (!valid_sv_ptp_clock_lease(lease) || + !continuity.unchanged(notified_session, current_faults, + lease.generation)) { + static_cast(controller.invalidate()); + revoke_sv_phase_session(notified_session); + continue; + } + + if (events != 1U || !live_tx_running() || + (xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U || + !same_sv_ptp_clock_lease(lease, read_ptp_epoch_clock_lease())) { + static_cast(controller.invalidate()); + revoke_sv_phase_session(notified_session); + continue; + } + + std::uint64_t before = 0U; + std::uint64_t after = 0U; + eth_mac_time_t ptp{}; + bool evidence_valid = false; + SvPtpPhaseObservation observation{}; + SvPtpClockLease armed_lease{}; + xSemaphoreTake(g_phase_timer_mutex, portMAX_DELAY); + const gptimer_handle_t timer = g_monitored_sample_clock; + if (timer != nullptr && notified_session != 0U && + g_sample_clock_context.session_generation.load( + std::memory_order_acquire) == notified_session) { + armed_lease = g_sample_clock_context.armed_ptp_clock; + const std::uint32_t generation_before = + g_sample_clock_context.epoch_generation.load( + std::memory_order_acquire); + const esp_err_t before_rc = gptimer_get_raw_count(timer, &before); + const esp_err_t ptp_rc = esp_eth_ioctl( + eth_handle, static_cast( + ETH_MAC_ESP_CMD_G_PTP_TIME), &ptp); + const esp_err_t after_rc = gptimer_get_raw_count(timer, &after); + const std::uint32_t generation_after = + g_sample_clock_context.epoch_generation.load( + std::memory_order_acquire); + if (before_rc == ESP_OK && ptp_rc == ESP_OK && + after_rc == ESP_OK && generation_before != 0U && + generation_before == generation_after && + ptp.nanoseconds >= 100000000U && + ptp.nanoseconds <= 800000000U) { + observation = SvPtpPhaseObservation{ + static_cast(before), + static_cast(after), + g_sample_clock_context.last_epoch_alarm.load( + std::memory_order_acquire), + generation_after, + static_cast(ptp.seconds), + ptp.nanoseconds}; + evidence_valid = true; + } + } + xSemaphoreGive(g_phase_timer_mutex); + + if (!evidence_valid || + !same_sv_ptp_clock_lease(armed_lease, lease) || + !same_sv_ptp_clock_lease(lease, read_ptp_epoch_clock_lease())) { + static_cast(controller.invalidate()); + revoke_sv_phase_session(notified_session); + continue; + } + + const auto decision = controller.observe(observation); + // Snapshot the independently stamped canonical SV evidence outside + // the GPTimer mutex. Source identity and fault/session generations + // must match the exact phase observation before any AUTO promotion. + bool source_local_qualified = false; +#if AR_SV_TX_EGRESS_SUPPORTED + if (lease.role == PtpAuthorityRole::source && + decision.accepted && decision.epoch_second_continuous) { + const std::int64_t abs_phase_us = decision.phase_error_us < 0 + ? -static_cast(decision.phase_error_us) + : static_cast(decision.phase_error_us); + const std::uint64_t worst_case_phase_us = + static_cast(abs_phase_us) + + decision.observation_uncertainty_us; + portENTER_CRITICAL(&g_tx_egress_mux); + source_local_qualified = g_local_sync_evidence.qualified( + notified_session, current_faults, lease.generation, + observation.last_sv_epoch_generation, + decision.software_tracking, + worst_case_phase_us <= UINT32_MAX + ? static_cast(worst_case_phase_us) + : UINT32_MAX); + portEXIT_CRITICAL(&g_tx_egress_mux); + } +#endif + // Handoff and GPTimer deletion share a control-plane mutex: + // neither a stale sample nor a recycled timer can receive a command. + bool committed = false; + xSemaphoreTake(g_phase_timer_mutex, portMAX_DELAY); + // Check fault generation at the serialized command handoff, not + // earlier while this task may be waiting for timer teardown. + const bool no_new_publisher_fault = + g_sample_clock_context.publisher_fault_generation.load( + std::memory_order_acquire) == current_faults; + const bool session_matches = notified_session != 0U && + g_monitored_sample_clock != nullptr && + g_sample_clock_context.session_generation.load( + std::memory_order_acquire) == notified_session; + if (session_matches && + g_sample_clock_context.epoch_generation.load( + std::memory_order_acquire) == observation.last_sv_epoch_generation && + decision.accepted && live_tx_running() && + same_sv_ptp_clock_lease( + armed_lease, g_sample_clock_context.armed_ptp_clock) && + same_sv_ptp_clock_lease(lease, read_ptp_epoch_clock_lease()) && + no_new_publisher_fault) { + // A single near-boundary observation is only a baseline. Do + // not arm ISR steering until adjacent smpCnt=0 deadlines map + // to adjacent *integer PTP seconds* in the same generation. + g_sample_clock_context.phase_authority_valid.store( + decision.epoch_second_continuous, std::memory_order_release); + g_sample_clock_context.requested_slew_ticks.store( + decision.epoch_second_continuous + ? decision.slew_ticks_next_epoch : 0, + std::memory_order_release); + smp_synch_set_source_local_qualified( + source_local_qualified && decision.epoch_second_continuous); + committed = true; + } else if (session_matches) { + g_sample_clock_context.phase_authority_valid.store( + false, std::memory_order_release); + g_sample_clock_context.requested_slew_ticks.store( + 0, std::memory_order_relaxed); + smp_synch_set_source_local_qualified(false); + } + xSemaphoreGive(g_phase_timer_mutex); + if (!committed) { + static_cast(controller.invalidate()); + continue; + } + ++accepted_epochs; + if ((accepted_epochs % 60U) == 0U) { + ESP_LOGI(kTag, + "SV/PTP phase SW evidence: phase=%ld us uncertainty=%lu us " + "slew=%ld ticks/epoch tracking=%u PTPepoch=%llu bound=%u AUTOlocal=%u", + static_cast(decision.phase_error_us), + static_cast(decision.observation_uncertainty_us), + static_cast(decision.slew_ticks_next_epoch), + decision.software_tracking ? 1U : 0U, + static_cast(decision.sv_epoch_ptp_second), + decision.epoch_second_continuous ? 1U : 0U, + smp_synch_source_local_qualified() ? 1U : 0U); + } + } +} +#endif + void telemetry_task(void*) { std::uint32_t previous_tick_total = 0U; std::int64_t previous_tick_time_us = 0; bool rate_baseline_valid = false; TimingStats baseline_stats{}; +#if AR_SV_TX_EGRESS_SUPPORTED + std::uint32_t previously_reported_egress = 0U; +#endif while (true) { vTaskDelay(pdMS_TO_TICKS(1000)); @@ -630,6 +1261,41 @@ void telemetry_task(void*) { snapshot = g_interval_stats; g_interval_stats = TimingStats{}; portEXIT_CRITICAL(&g_stats_mux); +#if AR_SV_TX_EGRESS_SUPPORTED + SvTxEgressSummary egress_snapshot{}; + portENTER_CRITICAL(&g_tx_egress_mux); + egress_snapshot = g_tx_egress_evidence.summary(); + portEXIT_CRITICAL(&g_tx_egress_mux); + if (egress_snapshot.attempted != previously_reported_egress) { + previously_reported_egress = egress_snapshot.attempted; + ESP_LOGI(kTag, + "SV MAC TX descriptor evidence (LAB, 1/16 smpCnt=0): " + "attempted=%lu baseline=%lu measured=%lu invalid=%lu txfail=%lu no_hw_ts=%lu " + "SV_discontinuity=%lu PTP_clock_step=%lu PTP_source_changed=%lu " + "PTP-egress-phase_ns[min/mean/max]=%ld/%lld/%ld " + "submit_max_us=%lu last_epoch=%lu last_alarm_ticks=%lu; " + "PTP/SV absolute accuracy and smpSynch NOT QUALIFIED", + static_cast(egress_snapshot.attempted), + static_cast(egress_snapshot.baseline_established), + static_cast(egress_snapshot.measured), + static_cast(egress_snapshot.rejected_epoch), + static_cast(egress_snapshot.failed_transmit), + static_cast(egress_snapshot.missing_hw_timestamp), + static_cast(egress_snapshot.publisher_discontinuities), + static_cast(egress_snapshot.ptp_clock_discontinuities), + static_cast(egress_snapshot.ptp_source_changes), + static_cast(egress_snapshot.measured > 0U + ? egress_snapshot.min_phase_ns : 0), + static_cast(egress_snapshot.measured > 0U + ? egress_snapshot.phase_sum_ns / + static_cast(egress_snapshot.measured) : 0LL), + static_cast(egress_snapshot.measured > 0U + ? egress_snapshot.max_phase_ns : 0), + static_cast(egress_snapshot.max_submit_duration_us), + static_cast(egress_snapshot.last_epoch_generation), + static_cast(egress_snapshot.last_alarm_ticks_low32)); + } +#endif const std::int64_t now_us = esp_timer_get_time(); const std::uint32_t tick_total = g_sample_tick_total.load(std::memory_order_relaxed); @@ -706,6 +1372,22 @@ extern "C" void app_main(void) { #if CONFIG_AR_SMV_LIVE_TX && CONFIG_AR_SMV_P2_REALTIME_50HZ runtime_profile_initialize(); +#if AR_SV_PTP_PHASE_SUPPORTED + g_phase_timer_mutex = xSemaphoreCreateMutex(); + if (g_phase_timer_mutex == nullptr) { + ESP_LOGE(kTag, "PTP/SV phase observer mutex allocation failed"); + return; + } + if (xTaskCreatePinnedToCore( + &sv_ptp_phase_observer_task, "ar_smv_ptp_phase", 6144, eth_handle, + 2, &g_phase_observer_task, 0) != pdPASS) { + ESP_LOGE(kTag, "Failed to create PTP/SV phase observer on CPU0"); + return; + } + ESP_LOGW(kTag, + "Experimental PTP/SV phase servo enabled: software-only evidence; " + "physical time accuracy and smpSynch qualification NOT established"); +#endif live_control_initialize( static_cast(CONFIG_AR_SMV_CURRENT_RMS_MA), static_cast(CONFIG_AR_SMV_VOLTAGE_RMS_MV / 10)); diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index 347760d8c..5f112b33e 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -3,7 +3,9 @@ #include "ptp_lab_task.hpp" #include "ariec61850/time_sync/ptp.hpp" +#include "ariec61850/time_sync/ptp_power_profile.hpp" #include "ariec61850/time_sync/ptp_runtime.hpp" +#include "ariec61850/time_sync/ptp_tai_source.hpp" #include "esp_err.h" #include "esp_eth.h" @@ -11,6 +13,7 @@ #include "esp_eth_mac.h" #include "esp_idf_version.h" #include "esp_log.h" +#include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/queue.h" #include "freertos/task.h" @@ -45,6 +48,10 @@ using ar::iec61850::time_sync::PtpMessageType; using ar::iec61850::time_sync::PtpPortIdentity; using ar::iec61850::time_sync::PtpPublisherOptions; using ar::iec61850::time_sync::PtpPublisherRuntime; +using ar::iec61850::time_sync::PtpSourceReadinessGate; +using ar::iec61850::time_sync::PtpSourceEvidenceFreshness; +using ar::iec61850::time_sync::conservative_lab_time_properties; +using ar::iec61850::time_sync::inspect_ptp_power_profile_cadence; using ar::iec61850::time_sync::PtpTimeSource; using ar::iec61850::time_sync::PtpTimestamp; using ar::iec61850::time_sync::format_ptp_clock_identity; @@ -78,6 +85,10 @@ struct PtpLabContext final { PtpLabContext g_ptp_context{}; std::atomic_bool g_ptp_started{false}; std::atomic_bool g_ptp_ready{false}; +std::atomic g_ptp_last_announce_us{0LL}; +std::atomic g_ptp_last_sync_pair_us{0LL}; +std::atomic g_ptp_freshness_timeout_us{3000000LL}; +std::atomic g_ptp_source_generation{0U}; std::atomic_bool g_stop_requested{false}; std::atomic_bool g_ptp_accept_rx{false}; std::atomic g_announce_sent{0U}; @@ -86,6 +97,15 @@ std::atomic g_follow_up_sent{0U}; std::atomic g_pdelay_frames_sent{0U}; std::atomic g_tx_failure_count{0U}; +[[nodiscard]] bool source_has_fresh_evidence() noexcept { + if (!g_ptp_ready.load(std::memory_order_acquire)) return false; + return PtpSourceEvidenceFreshness::fresh( + esp_timer_get_time(), + g_ptp_last_announce_us.load(std::memory_order_acquire), + g_ptp_last_sync_pair_us.load(std::memory_order_acquire), + g_ptp_freshness_timeout_us.load(std::memory_order_acquire)); +} + portMUX_TYPE g_control_mux = portMUX_INITIALIZER_UNLOCKED; ar_ptp_lab_config_t g_runtime_config_override{}; bool g_runtime_config_override_valid = false; @@ -201,11 +221,33 @@ void fill_kconfig_defaults(ar_ptp_lab_config_t& config) noexcept { [[nodiscard]] bool validate_runtime_config( const ar_ptp_lab_config_t& config, std::string& error) { + // LAB_SOURCE has no external clock input or traceability evidence. + // Reject source-quality advertising that would mislead an IED BMCA. + if (config.role == AR_PTP_ROLE_LAB_SOURCE && + (config.clock_class != 248U || + config.clock_accuracy != + static_cast(PtpClockAccuracy::unknown) || + config.time_source != + static_cast(PtpTimeSource::internal_oscillator))) { + error = "LAB_SOURCE requires clockClass=248, clockAccuracy=UNKNOWN " + "and timeSource=INTERNAL_OSCILLATOR (no external reference)"; + return false; + } if (config.clock_identity_override && all_zero_identity(config.clock_identity)) { error = "clockIdentity override must be non-zero"; return false; } - return options_from_config(config, kValidationMac, &error).has_value(); + auto options = options_from_config(config, kValidationMac, &error); + if (!options.has_value()) return false; +#if !defined(CONFIG_AR_PTP_ALLOW_NONSTANDARD_LAB_CADENCE) || !CONFIG_AR_PTP_ALLOW_NONSTANDARD_LAB_CADENCE + if (config.role == AR_PTP_ROLE_LAB_SOURCE && + !inspect_ptp_power_profile_cadence(*options).compatible()) { + error = "IEC/IEEE 61850-9-3 lab SOURCE requires 1s Announce, 1s Sync " + "and hardware Pdelay responder; nonstandard bench mode is opt-in"; + return false; + } +#endif + return true; } [[nodiscard]] std::optional make_runtime_options( @@ -288,20 +330,49 @@ void disable_hardware_ptp(const esp_eth_handle_t eth_handle) noexcept { } } -void seed_hardware_clock(const esp_eth_handle_t eth_handle) noexcept { +// Returns true only when the EMAC was seeded with an epoch expressed in +// PTP/TAI seconds. A plausible RTC is not an accuracy/traceability guarantee. +[[nodiscard]] bool seed_hardware_clock( + const esp_eth_handle_t eth_handle, + const std::int16_t configured_tai_utc_offset) 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 tai_seconds = ar::iec61850::time_sync::try_seed_ptp_tai_seconds( + static_cast(system_time), configured_tai_utc_offset); + if (tai_seconds.has_value()) initial_time.seconds = *tai_seconds; const auto result = esp_eth_ioctl( - eth_handle, - static_cast(ETH_MAC_ESP_CMD_S_PTP_TIME), + 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)); + ESP_LOGE(kTag, "Hardware PTP clock seed failed: %s", esp_err_to_name(result)); + return false; } + if (!tai_seconds.has_value()) { + ESP_LOGW(kTag, + "No plausible UTC epoch: EMAC PTP free-runs without PTP-timescale validity; " + "clock quality remains UNKNOWN (not IED traceable)"); + return false; + } + // A successful write ioctl alone does not prove that the MAC is operating + // on the intended timebase. Verify once at control-plane START, never + // during each SV sample or PTP packet. + eth_mac_time_t readback{}; + const auto read_result = esp_eth_ioctl( + eth_handle, static_cast(ETH_MAC_ESP_CMD_G_PTP_TIME), + &readback); + if (read_result != ESP_OK || readback.nanoseconds >= 1'000'000'000U || + static_cast(readback.seconds) < + static_cast(*tai_seconds) || + static_cast(readback.seconds) > + static_cast(*tai_seconds) + 1ULL) { + ESP_LOGE(kTag, + "Hardware PTP TAI seed read-back invalid; withholding PTP-timescale claim"); + return false; + } + ESP_LOGI(kTag, + "PTP/TAI seed applied from local RTC and configured UTC offset; " + "absolute time accuracy/traceability NOT established"); + return true; } [[nodiscard]] PtpTimestamp hardware_time(const esp_eth_handle_t eth_handle) noexcept { @@ -483,6 +554,9 @@ esp_err_t ptp_input_info( void finish_runtime(PtpLabContext& context) { g_ptp_accept_rx.store(false, std::memory_order_release); + g_ptp_ready.store(false, std::memory_order_release); + g_ptp_last_announce_us.store(0LL, std::memory_order_release); + g_ptp_last_sync_pair_us.store(0LL, std::memory_order_release); if (context.runtime.has_value()) { context.runtime->stop(); const auto status = context.runtime->status(); @@ -511,7 +585,9 @@ void ptp_lab_task(void* argument) { vTaskDelete(nullptr); return; } - seed_hardware_clock(context.eth_handle); + const auto selected_ptp_config = selected_config(); + const bool ptp_tai_epoch_seeded = seed_hardware_clock( + context.eth_handle, selected_ptp_config.current_utc_offset); const auto mac_result = esp_eth_ioctl( context.eth_handle, @@ -532,6 +608,19 @@ void ptp_lab_task(void* argument) { return; } + // IEEE 1588-2008 timeProperties must match the actual EMAC timescale. + // Flagging PTP/TAI does not imply traceability to UTC/GNSS. +#if defined(CONFIG_AR_PTP_MANUAL_UTC_OFFSET_VALID) && CONFIG_AR_PTP_MANUAL_UTC_OFFSET_VALID + constexpr bool kOperatorVerifiedTaiUtcOffset = true; +#else + constexpr bool kOperatorVerifiedTaiUtcOffset = false; +#endif + profile->time_properties = conservative_lab_time_properties( + ptp_tai_epoch_seeded, kOperatorVerifiedTaiUtcOffset); + if (!profile->time_properties.ptp_timescale) { + ESP_LOGW(kTag, "PTP timescale/UTC offset UNKNOWN: local RTC or " + "TAI-UTC offset not independently verified; no traceability claim"); + } context.runtime.emplace(std::move(*profile)); if (!context.runtime->start()) { ESP_LOGE(kTag, @@ -543,6 +632,13 @@ void ptp_lab_task(void* argument) { } const auto options = context.runtime->options(); + // Nonstandard bench cadences are explicitly opt-in, so expiry tracks the + // larger validated interval. Default power-profile TTL = 3 seconds. + const auto interval_ms = std::max( + options.announce_interval.count(), options.sync_interval.count()); + g_ptp_freshness_timeout_us.store( + static_cast(interval_ms) * 3000LL, + std::memory_order_release); context.rx_domain = options.domain_number; context.rx_transport_specific = options.transport_specific; context.rx_respond_to_peer_delay = options.respond_to_peer_delay; @@ -563,6 +659,16 @@ void ptp_lab_task(void* argument) { } } + // A Power Profile slave needs peer-delay replies to qualify network + // propagation time. Do not advertise a usable timing source when the + // requested hardware RX/Pdelay path could not be established. + if (options.respond_to_peer_delay && !pdelay_enabled) { + ESP_LOGE(kTag, "PTP SOURCE rejected: required hardware Pdelay response path unavailable"); + finish_runtime(context); + vTaskDelete(nullptr); + return; + } + const auto clock_identity = format_ptp_clock_identity(options.clock_identity); ESP_LOGW(kTag, "PTP LAB TX enabled: troubleshooting/interoperability helper only; not a GPS-backed or certified grandmaster"); @@ -578,7 +684,17 @@ void ptp_lab_task(void* argument) { pdelay_enabled ? "enabled" : "disabled"); std::uint32_t consecutive_failures = 0U; + PtpSourceReadinessGate source_readiness; while (!g_stop_requested.load(std::memory_order_acquire)) { + // A stalled/suspended SOURCE task must not carry yesterday's READY + // into a fresh Announce+Sync. Accessors also perform this live check. + if (g_ptp_ready.load(std::memory_order_acquire) && + !source_has_fresh_evidence()) { + source_readiness.invalidate(); + g_ptp_ready.store(false, std::memory_order_release); + g_ptp_last_announce_us.store(0LL, std::memory_order_release); + g_ptp_last_sync_pair_us.store(0LL, std::memory_order_release); + } bool attempted = false; bool success = true; @@ -586,25 +702,59 @@ void ptp_lab_task(void* argument) { while (context.pdelay_queue != nullptr && xQueueReceive(context.pdelay_queue, &pdelay_request, 0U) == pdTRUE) { attempted = true; - success = send_pdelay_response(context, pdelay_request) && success; + const bool response_delivered = send_pdelay_response(context, pdelay_request); + source_readiness.observe_peer_delay(response_delivered); + success = response_delivered && success; } const auto due = context.runtime->poll_due(); if (due.announce) { attempted = true; - success = send_announce(context) && success; + const bool announce_delivered = send_announce(context); + source_readiness.observe_announce(announce_delivered); + if (announce_delivered) { + g_ptp_last_announce_us.store( + esp_timer_get_time(), std::memory_order_release); + } + success = announce_delivered && success; } if (due.sync) { attempted = true; - success = send_sync_follow_up(context) && success; + // True only if the actual Sync TX timestamp AND matching Follow_Up + // TX succeeded; Sync alone never qualifies the source. + const bool pair_delivered = send_sync_follow_up(context); + source_readiness.observe_sync_pair(pair_delivered); + if (pair_delivered) { + g_ptp_last_sync_pair_us.store( + esp_timer_get_time(), std::memory_order_release); + } + success = pair_delivered && success; + } + // A failed peer-delay response in the same batch must also revoke + // any freshly sent core messages; no readiness resurrection by Pdelay. + if (!success) { + source_readiness.invalidate(); + g_ptp_last_announce_us.store(0LL, std::memory_order_release); + g_ptp_last_sync_pair_us.store(0LL, std::memory_order_release); + } + const bool qualified_ready = source_readiness.ready() && + PtpSourceEvidenceFreshness::fresh( + esp_timer_get_time(), + g_ptp_last_announce_us.load(std::memory_order_acquire), + g_ptp_last_sync_pair_us.load(std::memory_order_acquire), + g_ptp_freshness_timeout_us.load(std::memory_order_acquire)); + const bool was_ready = g_ptp_ready.load(std::memory_order_acquire); + if (!qualified_ready) { + g_ptp_ready.store(false, std::memory_order_release); + } else if (!was_ready) { + g_ptp_source_generation.fetch_add(1U, std::memory_order_release); + g_ptp_ready.store(true, std::memory_order_release); } if (attempted) { if (success) { consecutive_failures = 0U; - context.runtime->clear_error(); - const bool emitted_core_timing = g_announce_sent.load(std::memory_order_relaxed) > 0U && g_sync_sent.load(std::memory_order_relaxed) > 0U && g_follow_up_sent.load(std::memory_order_relaxed) > 0U; - if (emitted_core_timing) g_ptp_ready.store(true, std::memory_order_release); + if (qualified_ready) context.runtime->clear_error(); } else { context.runtime->record_error("Ethernet transmit or hardware timestamp failure"); g_tx_failure_count.fetch_add(1U, std::memory_order_relaxed); @@ -655,6 +805,8 @@ void start_ptp_lab(const esp_eth_handle_t eth_handle) { reset_live_status(); g_ptp_ready.store(false, std::memory_order_release); + g_ptp_last_announce_us.store(0LL, std::memory_order_release); + g_ptp_last_sync_pair_us.store(0LL, std::memory_order_release); g_ptp_accept_rx.store(false, std::memory_order_release); g_stop_requested.store(false, std::memory_order_release); g_ptp_context.eth_handle = eth_handle; @@ -689,7 +841,7 @@ void start_ptp_lab(const esp_eth_handle_t eth_handle) { constexpr unsigned kStartReadyPolls = 160U; for (unsigned attempt = 0U; attempt < kStartReadyPolls; ++attempt) { - if (g_ptp_ready.load(std::memory_order_acquire) || !g_ptp_started.load(std::memory_order_acquire)) return; + if (source_has_fresh_evidence() || !g_ptp_started.load(std::memory_order_acquire)) return; vTaskDelay(pdMS_TO_TICKS(10)); } ESP_LOGE(kTag, "PTP source readiness timeout: no verified Announce/Sync/Follow_Up TX"); @@ -711,7 +863,7 @@ void stop_ptp_lab() noexcept { } [[nodiscard]] bool ptp_lab_is_running() noexcept { - return g_ptp_ready.load(std::memory_order_acquire); + return source_has_fresh_evidence(); } [[nodiscard]] bool configure_ptp_lab(const ar_ptp_lab_config_t& config) { @@ -765,9 +917,21 @@ extern "C" bool ar_ptp_lab_is_running(void) { return ar::esp32p4::smv::ptp_lab_is_running(); } +// Lifetime/ownership is deliberately independent of measured PTP READY. +// Used only by the facade to prevent simultaneous EMAC timebase owners. +extern "C" bool ar_ptp_source_runtime_active(void) { + return ar::esp32p4::smv::g_ptp_started.load(std::memory_order_acquire); +} + +extern "C" std::uint32_t ar_ptp_source_generation(void) { + if (!ar::esp32p4::smv::ptp_lab_is_running()) return 0U; + return ar::esp32p4::smv::g_ptp_source_generation.load( + std::memory_order_acquire); +} + extern "C" bool ar_ptp_lab_get_status(ar_ptp_lab_status_t* status) { if (status == nullptr) return false; - status->is_running = ar::esp32p4::smv::g_ptp_ready.load(std::memory_order_acquire); + status->is_running = ar::esp32p4::smv::ptp_lab_is_running(); status->announce_sent = ar::esp32p4::smv::g_announce_sent.load(std::memory_order_relaxed); status->sync_sent = ar::esp32p4::smv::g_sync_sent.load(std::memory_order_relaxed); status->follow_up_sent = ar::esp32p4::smv::g_follow_up_sent.load(std::memory_order_relaxed); diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp index 432ced407..b29fd8ee2 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp @@ -94,6 +94,9 @@ typedef struct ar_ptp_lab_status_t { bool globally_traceable; bool measured_smp_synch_valid; uint8_t measured_smp_synch; + // Clock authority lease only; neither field proves SMV/PPS phase. + uint32_t timing_authority_generation; + bool timing_authority_clock_usable; } ar_ptp_lab_status_t; /** Fill a configuration snapshot from the firmware's Kconfig defaults. */ @@ -129,6 +132,25 @@ void ar_ptp_lab_stop(void); /** Return whether the selected PTP runtime is currently ready/running. */ bool ar_ptp_lab_is_running(void); +/** Nonzero only for a currently ready SOURCE. Increments when PTP SOURCE + * becomes ready after START or after a clock/TX failure; not traceability. */ +uint32_t ar_ptp_lab_source_generation(void); + +// Role-tagged clock lease. Not an SV phase or egress certificate. +typedef struct ar_ptp_timing_authority_t { + ar_ptp_role_t role; + uint32_t generation; + bool clock_usable; + bool source_selected; + uint8_t selected_source_clock_identity[8]; + uint16_t selected_source_port_number; + bool globally_traceable; + bool sv_phase_qualified; +} ar_ptp_timing_authority_t; + +/** Quiet fail-closed status projection for the PTP clock authority. */ +bool ar_ptp_lab_get_timing_authority(ar_ptp_timing_authority_t* authority); + /** Obtain a coherent live status snapshot for serial/GUI observability. */ bool ar_ptp_lab_get_status(ar_ptp_lab_status_t* status); diff --git a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp index fceb6d6c1..9637f748b 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp @@ -2,14 +2,19 @@ #include "ptp_lab_task.hpp" #include "ptp_receiver_task.hpp" +#include "smp_synch_lab.hpp" #include "ariec61850/time_sync/ptp_discipline.hpp" +#include "ariec61850/time_sync/ptp_canonical_authority.hpp" +#include "ariec61850/time_sync/ptp_emac_role_ownership.hpp" +#include "ariec61850/time_sync/ptp_control_cancellation.hpp" #include "esp_log.h" #include "freertos/FreeRTOS.h" #include "freertos/portmacro.h" #include "sdkconfig.h" +#include #include #include #include @@ -21,6 +26,8 @@ bool ar_ptp_source_configure(const ar_ptp_lab_config_t* config); void ar_ptp_source_start(esp_eth_handle_t eth_handle); void ar_ptp_source_stop(void); bool ar_ptp_source_is_running(void); +bool ar_ptp_source_runtime_active(void); +uint32_t ar_ptp_source_generation(void); bool ar_ptp_source_get_status(ar_ptp_lab_status_t* status); } @@ -34,8 +41,26 @@ constexpr char kTag[] = "ar_ptp_mode"; portMUX_TYPE g_ptp_facade_mux = portMUX_INITIALIZER_UNLOCKED; ar_ptp_lab_config_t g_facade_override{}; bool g_facade_override_valid = false; -std::atomic g_active_role{static_cast(AR_PTP_ROLE_LAB_SOURCE)}; std::atomic_bool g_receiver_stop_pending{false}; +ar::iec61850::time_sync::PtpControlCancellationGate g_role_control; + +void stop_active_ptp_roles() noexcept; + +struct RoleControlClaim final { + bool held{g_role_control.try_acquire()}; + [[nodiscard]] bool finish() noexcept { + if (!held) return false; + held = false; + return g_role_control.finish(stop_active_ptp_roles); + } + ~RoleControlClaim() noexcept { + if (held) static_cast(g_role_control.finish(stop_active_ptp_roles)); + } + RoleControlClaim(const RoleControlClaim&) = delete; + RoleControlClaim& operator=(const RoleControlClaim&) = delete; + RoleControlClaim() = default; +}; + void fill_p2_defaults(ar_ptp_lab_config_t& config) noexcept { config.role = static_cast(CONFIG_AR_PTP_OPERATING_ROLE); @@ -89,17 +114,36 @@ void fill_p2_defaults(ar_ptp_lab_config_t& config) noexcept { return has_override ? config : default_config(); } -[[nodiscard]] ar_ptp_role_t active_role() noexcept { - return static_cast(g_active_role.load(std::memory_order_acquire)); -} - [[nodiscard]] bool receiver_stop_cleanup_pending() noexcept { if (!g_receiver_stop_pending.load(std::memory_order_acquire)) return false; - if (ptp_receiver_is_running()) return true; + if (ptp_receiver_runtime_active()) return true; g_receiver_stop_pending.store(false, std::memory_order_release); return false; } +// Ownership is derived from live workers, never from a stale selected role. +void stop_active_ptp_roles() noexcept { + // Operator STOP or role handoff revokes the transmitted AUTO local-sync + // claim immediately, independent of the slower phase observer cadence. + smp_synch_set_source_local_qualified(false); + if (ar_ptp_source_runtime_active()) { + ar_ptp_source_stop(); + } + if (ptp_receiver_runtime_active()) { + g_receiver_stop_pending.store(true, std::memory_order_release); + ptp_receiver_stop(); + } +} + +[[nodiscard]] bool emac_role_free() noexcept { + const bool pending = receiver_stop_cleanup_pending(); + return ar::iec61850::time_sync::can_change_ptp_emac_role({ + ar_ptp_source_runtime_active(), + ptp_receiver_runtime_active(), + pending, + }); +} + [[nodiscard]] const char* role_name(const ar_ptp_role_t role) noexcept { switch (role) { case AR_PTP_ROLE_LAB_SOURCE: return "SOURCE"; @@ -120,6 +164,24 @@ void fill_p2_defaults(ar_ptp_lab_config_t& config) noexcept { return "FAULT"; } +[[nodiscard]] ar::iec61850::time_sync::PtpCanonicalAuthoritySnapshot +canonical_ptp_authority_status(const ar_ptp_lab_status_t& status) noexcept { + using namespace ar::iec61850::time_sync; + PtpCanonicalAuthorityEvidence e{}; + e.role = static_cast(status.role); + e.runtime_ready = status.is_running; + e.generation = status.timing_authority_generation; + e.clock_usable_claim = status.timing_authority_clock_usable; + e.selected_source = status.source_selected; + std::copy_n(status.selected_source_clock_identity, 8U, + e.source_clock_identity.begin()); + e.source_port = status.selected_source_port_number; + e.measured_discipline_locked = + status.discipline_state == AR_PTP_DISCIPLINE_LOCKED; + e.globally_traceable_claim = status.globally_traceable; + return canonical_ptp_authority(e); +} + void log_p2_status(const ar_ptp_lab_status_t& status) noexcept { char source[32]{}; if (status.source_selected) { @@ -177,17 +239,20 @@ extern "C" void ar_ptp_lab_get_default_config(ar_ptp_lab_config_t* config) { } extern "C" bool ar_ptp_lab_configure(const ar_ptp_lab_config_t* config) { - if (config == nullptr || ar_ptp_source_is_running() || ar::esp32p4::smv::ptp_receiver_is_running() || ar::esp32p4::smv::receiver_stop_cleanup_pending() || !ar::esp32p4::smv::valid_public_config(*config)) return false; + if (config == nullptr) return false; + ar::esp32p4::smv::RoleControlClaim transaction{}; + if (!transaction.held || !ar::esp32p4::smv::emac_role_free() || + !ar::esp32p4::smv::valid_public_config(*config)) return false; if (!ar_ptp_source_configure(config)) return false; portENTER_CRITICAL(&ar::esp32p4::smv::g_ptp_facade_mux); - if (ar_ptp_source_is_running() || ar::esp32p4::smv::ptp_receiver_is_running() || ar::esp32p4::smv::g_receiver_stop_pending.load(std::memory_order_acquire)) { + if (!ar::esp32p4::smv::emac_role_free()) { portEXIT_CRITICAL(&ar::esp32p4::smv::g_ptp_facade_mux); return false; } ar::esp32p4::smv::g_facade_override = *config; ar::esp32p4::smv::g_facade_override_valid = true; portEXIT_CRITICAL(&ar::esp32p4::smv::g_ptp_facade_mux); - return true; + return transaction.finish(); } extern "C" bool ar_ptp_lab_get_config(ar_ptp_lab_config_t* config) { @@ -197,32 +262,29 @@ extern "C" bool ar_ptp_lab_get_config(ar_ptp_lab_config_t* config) { } extern "C" bool ar_ptp_lab_try_start(const esp_eth_handle_t eth_handle) { - if (eth_handle == nullptr || ar_ptp_source_is_running()) return false; - - // A STOP request for receiver/monitor is asynchronous. Never interpret the - // still-ready old runtime as acceptance of a new START. Reject until the - // worker has completed cleanup, then clear the facade stop gate. - if (ar::esp32p4::smv::receiver_stop_cleanup_pending() || - ar::esp32p4::smv::ptp_receiver_is_running()) { - return false; - } + if (eth_handle == nullptr) return false; + ar::esp32p4::smv::RoleControlClaim transaction{}; + if (!transaction.held || !ar::esp32p4::smv::emac_role_free()) return false; const auto config = ar::esp32p4::smv::selected_config(); if (!ar::esp32p4::smv::valid_public_config(config)) { ESP_LOGE(ar::esp32p4::smv::kTag, - "PTP start rejected: invalid role/cadence/discipline threshold combination"); + "PTP start rejected: invalid role/cadence/discipline thresholds"); return false; } - ar::esp32p4::smv::g_active_role.store( - static_cast(config.role), std::memory_order_release); + bool started = false; if (config.role == AR_PTP_ROLE_LAB_SOURCE) { if (!ar_ptp_source_configure(&config)) return false; ar_ptp_source_start(eth_handle); - return ar_ptp_source_is_running(); + started = ar_ptp_source_is_running(); + } else { + started = ar::esp32p4::smv::ptp_receiver_start(eth_handle, config); } - - return ar::esp32p4::smv::ptp_receiver_start(eth_handle, config); + // STOP can race with the bounded blocking START: it must win the + // cancellation handshake before START may report READY to its caller. + const bool uncancelled = transaction.finish(); + return started && uncancelled; } extern "C" void ar_ptp_lab_start(const esp_eth_handle_t eth_handle) { @@ -230,37 +292,90 @@ extern "C" void ar_ptp_lab_start(const esp_eth_handle_t eth_handle) { } extern "C" void ar_ptp_lab_stop(void) { - if (ar::esp32p4::smv::active_role() == AR_PTP_ROLE_LAB_SOURCE) { - ar_ptp_source_stop(); - return; + // Publish the cancellation immediately. If START/config owns the + // slot, that owner drains STOP at the atomic release boundary. + if (ar::esp32p4::smv::g_role_control.request_stop_and_try_acquire()) { + static_cast(ar::esp32p4::smv::g_role_control.finish( + ar::esp32p4::smv::stop_active_ptp_roles)); } - if (ar::esp32p4::smv::ptp_receiver_is_running()) { - ar::esp32p4::smv::g_receiver_stop_pending.store(true, std::memory_order_release); - } - ar::esp32p4::smv::ptp_receiver_stop(); } extern "C" bool ar_ptp_lab_is_running(void) { - return ar_ptp_source_is_running() || ar::esp32p4::smv::ptp_receiver_is_running(); + return !ar::esp32p4::smv::g_role_control.stop_requested() && + (ar_ptp_source_is_running() || ar::esp32p4::smv::ptp_receiver_is_running()); } -extern "C" bool ar_ptp_lab_get_status(ar_ptp_lab_status_t* status) { - if (status == nullptr) return false; - *status = {}; - bool result = false; - if (ar_ptp_source_is_running()) { - result = ar_ptp_source_get_status(status); - if (result) { - status->role = AR_PTP_ROLE_LAB_SOURCE; - status->discipline_state = AR_PTP_DISCIPLINE_UNLOCKED; - } - } else if (ar::esp32p4::smv::ptp_receiver_is_running()) { - result = ar::esp32p4::smv::ptp_receiver_get_status(*status); +extern "C" uint32_t ar_ptp_lab_source_generation(void) { + return !ar::esp32p4::smv::g_role_control.stop_requested() && + ar_ptp_source_is_running() ? ar_ptp_source_generation() : 0U; +} + +namespace ar::esp32p4::smv { +namespace { + +// Same quiet authority for serial/GUI status and clock-lease consumers. +// Verify SOURCE generation on both sides of status reading; no stale +// generation may be paired with a new START's ready flag. +[[nodiscard]] bool collect_ptp_status(ar_ptp_lab_status_t& status) noexcept { + status = {}; + bool ok = true; + if (ar_ptp_source_runtime_active()) { + const auto before = ar_ptp_source_generation(); + ok = ar_ptp_source_get_status(&status); + const auto after = ar_ptp_source_generation(); + status.role = AR_PTP_ROLE_LAB_SOURCE; + status.discipline_state = AR_PTP_DISCIPLINE_UNLOCKED; + status.is_running = ok && status.is_running && + ar_ptp_source_is_running() && before != 0U && before == after; + status.timing_authority_generation = status.is_running ? after : 0U; + status.timing_authority_clock_usable = status.is_running; + } else if (ptp_receiver_runtime_active()) { + ok = ptp_receiver_get_status(status); } else { - status->role = ar::esp32p4::smv::selected_config().role; - status->discipline_state = AR_PTP_DISCIPLINE_UNLOCKED; - result = true; + status.role = selected_config().role; + status.discipline_state = AR_PTP_DISCIPLINE_UNLOCKED; + } + if (!ok) return false; + if (g_role_control.stop_requested()) { + status.is_running = false; + status.timing_authority_clock_usable = false; + status.globally_traceable = false; + status.measured_smp_synch_valid = false; + status.offset_valid = false; + status.timing_authority_generation = 0U; } + const auto lease = canonical_ptp_authority_status(status); + status.timing_authority_clock_usable = lease.clock_usable; + status.globally_traceable = lease.globally_traceable; + return true; +} + +} // namespace +} // namespace ar::esp32p4::smv + +extern "C" bool ar_ptp_lab_get_timing_authority( + ar_ptp_timing_authority_t* const authority) { + if (authority == nullptr) return false; + *authority = {}; + ar_ptp_lab_status_t status{}; + if (!ar::esp32p4::smv::collect_ptp_status(status)) return false; + const auto lease = ar::esp32p4::smv::canonical_ptp_authority_status(status); + authority->role = status.role; + authority->generation = lease.generation; + authority->clock_usable = lease.clock_usable; + authority->source_selected = lease.selected_source; + std::copy(lease.source_clock_identity.begin(), + lease.source_clock_identity.end(), + authority->selected_source_clock_identity); + authority->selected_source_port_number = lease.source_port; + authority->globally_traceable = lease.globally_traceable; + authority->sv_phase_qualified = lease.sv_phase_qualified; + return true; +} + +extern "C" bool ar_ptp_lab_get_status(ar_ptp_lab_status_t* const status) { + if (status == nullptr) return false; + const bool result = ar::esp32p4::smv::collect_ptp_status(*status); if (result) ar::esp32p4::smv::log_p2_status(*status); return result; } diff --git a/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp index 74d5efda0..8dde08992 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp @@ -7,6 +7,7 @@ #include "ariec61850/time_sync/ptp.hpp" #include "ariec61850/time_sync/ptp_discipline.hpp" #include "ariec61850/time_sync/ptp_runtime.hpp" +#include "ariec61850/time_sync/ptp_receiver_clock_authority.hpp" #include "esp_err.h" #include "esp_eth.h" @@ -103,6 +104,9 @@ struct ReceiverContext final { std::optional discipline; std::int32_t applied_frequency_ppb{}; std::uint16_t pdelay_sequence{1U}; + // Only receiver worker advances this; retain sequence across restart. + ar::iec61850::time_sync::PtpReceiverClockAuthority clock_authority{}; + bool clock_discontinuity_pending{}; }; ReceiverContext g_receiver_context{}; @@ -118,6 +122,7 @@ ar_ptp_lab_status_t g_receiver_status{}; [[nodiscard]] bool receiver_runtime_ready() noexcept { return g_receiver_running.load(std::memory_order_acquire) && + !g_receiver_stop_requested.load(std::memory_order_acquire) && g_receiver_startup_state.load(std::memory_order_acquire) == ReceiverStartupState::ready; } @@ -281,6 +286,7 @@ void seed_hardware_clock(const esp_eth_handle_t handle) noexcept { switch (command.kind) { case PtpClockCommandKind::none: return true; case PtpClockCommandKind::step_phase: + context.clock_discontinuity_pending = true; return apply_phase_step(context.eth_handle, command.phase_step_ns); case PtpClockCommandKind::set_frequency: return apply_frequency_adjustment( @@ -590,12 +596,29 @@ void update_status(ReceiverContext& context) noexcept { snapshot.discipline_state = AR_PTP_DISCIPLINE_UNLOCKED; } + ar::iec61850::time_sync::PtpReceiverClockObservation evidence{}; + evidence.runtime_ready = snapshot.is_running && + context.config.role == AR_PTP_ROLE_TIME_RECEIVER; + evidence.source_selected = snapshot.source_selected; + std::copy_n(snapshot.selected_source_clock_identity, 8U, + evidence.source_clock_identity.begin()); + evidence.source_port_number = snapshot.selected_source_port_number; + evidence.discipline_locked = + snapshot.discipline_state == AR_PTP_DISCIPLINE_LOCKED; + evidence.clock_discontinuity = context.clock_discontinuity_pending; + const auto lease = context.clock_authority.observe(evidence); + snapshot.timing_authority_generation = + context.config.role == AR_PTP_ROLE_TIME_RECEIVER ? lease.generation : 0U; + snapshot.timing_authority_clock_usable = lease.clock_usable; + context.clock_discontinuity_pending = false; + portENTER_CRITICAL(&g_receiver_status_mux); g_receiver_status = snapshot; portEXIT_CRITICAL(&g_receiver_status_mux); } void reset_discipline_for_new_source(ReceiverContext& context) noexcept { + context.clock_discontinuity_pending = true; if (!context.discipline.has_value()) return; if (!apply_frequency_adjustment( context.eth_handle, 0, context.applied_frequency_ppb)) { @@ -655,6 +678,7 @@ void process_rx_event(ReceiverContext& context, const PtpRxEvent& event) { if (measurement.has_value() && context.discipline.has_value()) { const auto command = context.discipline->observe(*measurement, now); if (!apply_clock_command(context, command)) { + context.clock_discontinuity_pending = true; context.discipline->record_actuation_failure(); } } @@ -666,6 +690,7 @@ void process_rx_event(ReceiverContext& context, const PtpRxEvent& event) { const auto previous_state = context.discipline->status().state; const auto command = context.discipline->observe(*measurement, now); if (!apply_clock_command(context, command)) { + context.clock_discontinuity_pending = true; context.discipline->record_actuation_failure(); } const auto state = context.discipline->status().state; @@ -815,6 +840,7 @@ void receiver_task(void* argument) { const auto now = std::chrono::steady_clock::now(); const bool source_dropped = context.receiver->tick(now); if (source_dropped) { + context.clock_discontinuity_pending = true; ESP_LOGW(kTag, "Selected PTP source timed out"); if (context.discipline.has_value()) { context.discipline->revoke_global_traceability(); @@ -888,6 +914,7 @@ bool ptp_receiver_start( g_receiver_context.discipline.reset(); g_receiver_context.applied_frequency_ppb = 0; g_receiver_context.pdelay_sequence = 1U; + g_receiver_context.clock_discontinuity_pending = false; if (g_receiver_context.rx_queue == nullptr) { g_receiver_context.rx_queue = xQueueCreate(kRxQueueDepth, sizeof(PtpRxEvent)); @@ -915,7 +942,12 @@ bool ptp_receiver_start( g_receiver_running.store(false, std::memory_order_release); return false; } - update_status(g_receiver_context); + // Do not mutate worker-owned clock lease from the START caller. + ar_ptp_lab_status_t pending_status{}; + pending_status.role = config.role; + portENTER_CRITICAL(&g_receiver_status_mux); + g_receiver_status = pending_status; + portEXIT_CRITICAL(&g_receiver_status_mux); const TickType_t started_at = xTaskGetTickCount(); while (true) { @@ -960,11 +992,22 @@ bool ptp_receiver_is_running() noexcept { return receiver_runtime_ready(); } +bool ptp_receiver_runtime_active() noexcept { + return g_receiver_running.load(std::memory_order_acquire); +} + bool ptp_receiver_get_status(ar_ptp_lab_status_t& status) noexcept { portENTER_CRITICAL(&g_receiver_status_mux); status = g_receiver_status; portEXIT_CRITICAL(&g_receiver_status_mux); - status.is_running = receiver_runtime_ready(); + // Never reuse worker's last LOCKED snapshot across STOP/cleanup. + status.is_running = status.is_running && receiver_runtime_ready(); + if (!status.is_running) { + status.timing_authority_clock_usable = false; + status.measured_smp_synch_valid = false; + status.globally_traceable = false; + status.offset_valid = false; + } return true; } diff --git a/embedded/esp32p4_smv_injector/main/ptp_receiver_task.hpp b/embedded/esp32p4_smv_injector/main/ptp_receiver_task.hpp index c825f858f..7d68428e3 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_receiver_task.hpp +++ b/embedded/esp32p4_smv_injector/main/ptp_receiver_task.hpp @@ -18,6 +18,8 @@ void ptp_receiver_stop() noexcept; /** True only after hardware PTP + RX timestamp callback readiness completes. */ [[nodiscard]] bool ptp_receiver_is_running() noexcept; +// Unlike READY, this includes START pending, failure cleanup and async STOP. +[[nodiscard]] bool ptp_receiver_runtime_active() noexcept; [[nodiscard]] bool ptp_receiver_get_status(ar_ptp_lab_status_t& status) noexcept; diff --git a/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp b/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp index 820b3b1ad..4f2779124 100644 --- a/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp +++ b/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "smp_synch_lab.hpp" +#include "ariec61850/time_sync/sv_ptp_epoch.hpp" #include #include @@ -15,6 +16,9 @@ std::atomic g_mode{ static_cast(SvSyncPolicyMode::external_ptp_auto)}; // -1 means PTP-P2 has not supplied measured lock evidence yet. std::atomic g_measured_value{-1}; +// Fail-closed until an independent SV timeline/phase acceptance gate exists. +std::atomic_bool g_sv_timeline_phase_qualified{false}; +std::atomic_bool g_source_local_qualified{false}; [[nodiscard]] std::optional measured_value() noexcept { const int raw = g_measured_value.load(std::memory_order_acquire); @@ -30,6 +34,17 @@ std::atomic g_measured_value{-1}; } } +[[nodiscard]] std::optional auto_evidence() noexcept { + // SOURCE has no receiver discipline object. Its local-sync claim + // originates exclusively from the sample phase + canonical TX gate. + // Global sync is reserved for measured external time traceability. + if (g_source_local_qualified.load(std::memory_order_acquire)) + return SmpSynchValue::local_synchronized; + return ar::iec61850::time_sync::qualified_sv_sync_value( + measured_value(), g_sv_timeline_phase_qualified.load( + std::memory_order_acquire)); +} + [[nodiscard]] SvSyncPolicyMode current_mode() noexcept { return static_cast( g_mode.load(std::memory_order_acquire)); @@ -46,11 +61,11 @@ SvSyncPolicyMode smp_synch_lab_mode() noexcept { } SmpSynchDecision smp_synch_lab_decision() noexcept { - return resolve_sv_sync_policy(current_mode(), measured_value()); + return resolve_sv_sync_policy(current_mode(), auto_evidence()); } SmpSynchLabStatus smp_synch_lab_status() noexcept { - const auto measured = measured_value(); + const auto measured = auto_evidence(); return { resolve_sv_sync_policy(current_mode(), measured), measured.has_value(), @@ -67,4 +82,12 @@ void smp_synch_lab_set_measured(const std::optional value) noexce std::memory_order_release); } +void smp_synch_set_source_local_qualified(const bool qualified) noexcept { + g_source_local_qualified.store(qualified, std::memory_order_release); +} + +bool smp_synch_source_local_qualified() noexcept { + return g_source_local_qualified.load(std::memory_order_acquire); +} + } // namespace ar::esp32p4::smv diff --git a/embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp b/embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp index f35544d52..61eefbace 100644 --- a/embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp +++ b/embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp @@ -31,4 +31,10 @@ void smp_synch_lab_set_mode(SvSyncPolicyMode mode) noexcept; */ void smp_synch_lab_set_measured(std::optional value) noexcept; +/** SOURCE AUTO only: assert local sync after independent scheduled-phase AND + * canonical MAC TX evidence have been qualified. Must revoke on STOP, source + * change, missed slot, TX failure or stale phase; never claims global sync. */ +void smp_synch_set_source_local_qualified(bool qualified) noexcept; +[[nodiscard]] bool smp_synch_source_local_qualified() noexcept; + } // namespace ar::esp32p4::smv diff --git a/embedded/esp32p4_smv_injector/sdkconfig.defaults b/embedded/esp32p4_smv_injector/sdkconfig.defaults index 3ca61f730..95776790c 100644 --- a/embedded/esp32p4_smv_injector/sdkconfig.defaults +++ b/embedded/esp32p4_smv_injector/sdkconfig.defaults @@ -30,7 +30,7 @@ CONFIG_AR_PTP_TRANSPORT_SPECIFIC=0 CONFIG_AR_PTP_PORT_NUMBER=1 # CONFIG_AR_PTP_VLAN is not set CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS=1000 -CONFIG_AR_PTP_SYNC_INTERVAL_MS=250 +CONFIG_AR_PTP_SYNC_INTERVAL_MS=1000 CONFIG_AR_PTP_RESPOND_PDELAY=y CONFIG_AR_PTP_PDELAY_REQUEST_INTERVAL_MS=1000 CONFIG_AR_PTP_PRIORITY1=128 @@ -46,6 +46,6 @@ CONFIG_AR_PTP_LOCK_OFFSET_NS=2000 CONFIG_AR_PTP_UNLOCK_OFFSET_NS=20000 CONFIG_AR_PTP_PHASE_STEP_THRESHOLD_NS=1000000 CONFIG_AR_PTP_LOCK_REQUIRED_SAMPLES=8 -CONFIG_AR_PTP_SYNC_TIMEOUT_MS=1000 +CONFIG_AR_PTP_SYNC_TIMEOUT_MS=3000 CONFIG_AR_PTP_HOLDOVER_TIMEOUT_MS=5000 CONFIG_AR_PTP_MAX_FREQ_ADJ_PPB=100000 diff --git a/embedded/esp32p4_smv_injector/tools/stage_ptp_experimental.py b/embedded/esp32p4_smv_injector/tools/stage_ptp_experimental.py new file mode 100644 index 000000000..054488203 --- /dev/null +++ b/embedded/esp32p4_smv_injector/tools/stage_ptp_experimental.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""Stage an opt-in, non-release ESP32-P4 PTP firmware test artifact.""" +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import sys + +def main() -> int: + if len(sys.argv) != 3 or sys.argv[1] not in {"source", "receiver"}: + raise SystemExit("usage: stage_ptp_experimental.py source|receiver DEST_ROOT") + role = sys.argv[1] + destination = Path(sys.argv[2]).resolve() / role + root = Path.cwd().resolve() + build = root / "build" + head = os.environ.get("ARSTACK_SOURCE_COMMIT", "") + if not re.fullmatch(r"[0-9a-f]{40}", head): + raise SystemExit("require exact 40-character GitHub PR head SHA") + config_path = root / "sdkconfig" + config = config_path.read_text(encoding="utf-8") + expected = { + "CONFIG_IDF_TARGET": '"esp32p4"', + "CONFIG_AR_PTP_OPERATING_ROLE": "0" if role == "source" else "1", + "CONFIG_AR_PTP_LAB_TX": "y", + "CONFIG_AR_SMV_LIVE_TX": "y", + "CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL": "y", + "CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL": "y", + "CONFIG_ETH_TRANSMIT_MUTEX": "y", + } + for key, value in expected.items(): + if f"{key}={value}" not in config.splitlines(): + raise SystemExit(f"unsafe {role} artifact: missing {key}={value}") + if "# CONFIG_AR_SMV_BROADCAST_MIRROR is not set" not in config: + raise SystemExit("diagnostic broadcast mirror must be OFF") + + flasher_file = build / "flasher_args.json" + flasher = json.loads(flasher_file.read_text(encoding="utf-8")) + flash_files = flasher.get("flash_files") + if not isinstance(flash_files, dict) or len(flash_files) < 3: + raise SystemExit("missing complete ESP-IDF flasher_args flash_files") + image_names = [str(x) for x in flash_files.values()] + if not all(name.endswith(".bin") for name in image_names): + raise SystemExit("flash_files contains non-binary image") + if not any("bootloader" in name for name in image_names) or not any( + "partition" in name for name in image_names + ) or not any("/" not in name for name in image_names): + raise SystemExit("bootloader, partition table, or app image missing") + + destination.mkdir(parents=True, exist_ok=False) + shutil.copy2(config_path, destination / "sdkconfig") + shutil.copy2(flasher_file, destination / "flasher_args.json") + images = [] + for offset, name in sorted(flash_files.items(), key=lambda x: int(x[0], 0)): + relative = Path(name) + if relative.is_absolute() or ".." in relative.parts: + raise SystemExit(f"invalid flash image path {name!r}") + source = (build / relative).resolve() + if not source.is_relative_to(build) or not source.is_file() or source.stat().st_size == 0: + raise SystemExit(f"missing or unsafe flash image {name!r}") + target = destination / "build" / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + images.append({ + "flashOffset": offset, + "relativeFile": str(Path("build") / relative), + "bytes": source.stat().st_size, + "sha256": hashlib.sha256(source.read_bytes()).hexdigest(), + }) + + manifest = { + "kind": "ARStack61850 experimental PTP bench-only; NOT a stable release", + "boardTarget": "esp32p4", + "role": role, + "prHeadCommit": head, + "checkoutCommit": os.environ.get("GITHUB_SHA", ""), + "semanticBuildId": head[:16], + "idfImageSource": "build/flasher_args.json", + "smpSynchAutoQualification": False, + "hardwareValidated": False, + "flashImages": images, + "sdkconfigSha256": hashlib.sha256(config_path.read_bytes()).hexdigest(), + } + (destination / "manifest.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + print(f"Experimental {role}: {len(images)} verified flash images, PR head {head}") + return 0 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/include/ariec61850/capture/pcap.hpp b/include/ariec61850/capture/pcap.hpp index 3e8462a1e..9debb81ca 100644 --- a/include/ariec61850/capture/pcap.hpp +++ b/include/ariec61850/capture/pcap.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -25,6 +26,16 @@ struct PcapPacket final { class PcapReader final { public: + // One bounded PCAP packet at a time. Packet ownership is transferred to + // the callback and is discarded unless the callback explicitly retains it. + // Uses the exact same parser as read_all; no duplicate PCAP implementation. + using PacketVisitor = std::function; + + static void for_each_packet(const std::filesystem::path& file_path, + const PacketVisitor& visitor); + static void for_each_packet(std::istream& stream, + const PacketVisitor& visitor); + [[nodiscard]] static std::vector read_all(const std::filesystem::path& file_path); [[nodiscard]] static std::vector read_all(std::istream& stream); }; diff --git a/include/ariec61850/time_sync/ptp.hpp b/include/ariec61850/time_sync/ptp.hpp index f9796d9b6..9f77fd5ac 100644 --- a/include/ariec61850/time_sync/ptp.hpp +++ b/include/ariec61850/time_sync/ptp.hpp @@ -131,6 +131,24 @@ struct PtpFrame final { bool peer_delay_multicast{}; }; +// IEEE 1588-2008 flagField[1] (low octet of the wire flagField). +// These are time-properties statements, distinct from the twoStep flag. +// Unknown time provenance is represented by all flags false. +struct PtpTimeProperties final { + bool current_utc_offset_valid{}; + bool ptp_timescale{}; + bool time_traceable{}; + bool frequency_traceable{}; + + [[nodiscard]] constexpr std::uint16_t flags() const noexcept { + return static_cast( + (current_utc_offset_valid ? 0x0004U : 0U) | + (ptp_timescale ? 0x0008U : 0U) | + (time_traceable ? 0x0010U : 0U) | + (frequency_traceable ? 0x0020U : 0U)); + } +}; + struct PtpBuildOptions final { std::uint8_t transport_specific{}; std::uint8_t domain_number{}; @@ -139,6 +157,7 @@ struct PtpBuildOptions final { std::int64_t correction_field{}; std::int8_t log_message_interval{}; bool two_step{true}; + PtpTimeProperties time_properties{}; std::uint8_t priority1{128U}; std::uint8_t priority2{128U}; std::uint8_t clock_class{248U}; diff --git a/include/ariec61850/time_sync/ptp_canonical_authority.hpp b/include/ariec61850/time_sync/ptp_canonical_authority.hpp new file mode 100644 index 000000000..d0263917b --- /dev/null +++ b/include/ariec61850/time_sync/ptp_canonical_authority.hpp @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include +#include + +namespace ar::iec61850::time_sync { + +// Pure, constant-space projection of the canonical runtime status. A PTP +// clock lease is NOT an SV phase/egress certificate. +enum class PtpAuthorityRole : std::uint8_t { + source = 0U, receiver = 1U, monitor = 2U, +}; +struct PtpCanonicalAuthorityEvidence final { + PtpAuthorityRole role{PtpAuthorityRole::monitor}; + bool runtime_ready{}; + std::uint32_t generation{}; + bool clock_usable_claim{}; + bool selected_source{}; + std::array source_clock_identity{}; + std::uint16_t source_port{}; + bool measured_discipline_locked{}; + bool globally_traceable_claim{}; +}; +struct PtpCanonicalAuthoritySnapshot final { + PtpAuthorityRole role{PtpAuthorityRole::monitor}; + std::uint32_t generation{}; + bool clock_usable{}; + bool selected_source{}; + std::array source_clock_identity{}; + std::uint16_t source_port{}; + bool globally_traceable{}; + // Separate SV phase/egress qualification has not been implemented. + // Never infer it from PTP lock or traceability. + bool sv_phase_qualified{}; +}; +[[nodiscard]] constexpr PtpCanonicalAuthoritySnapshot +canonical_ptp_authority(const PtpCanonicalAuthorityEvidence& e) noexcept { + PtpCanonicalAuthoritySnapshot result{}; + result.role = e.role; + result.generation = e.generation; + const bool live_lease = e.runtime_ready && e.generation != 0U && + e.clock_usable_claim; + if (e.role == PtpAuthorityRole::source) { + // A ready LAB_SOURCE clock does not imply globally traceable time. + result.clock_usable = live_lease; + } else if (e.role == PtpAuthorityRole::receiver) { + result.selected_source = e.runtime_ready && e.selected_source; + if (result.selected_source) { + result.source_clock_identity = e.source_clock_identity; + result.source_port = e.source_port; + } + result.clock_usable = live_lease && result.selected_source && + e.measured_discipline_locked; + result.globally_traceable = result.clock_usable && + e.globally_traceable_claim; + } + return result; // MONITOR and unknown roles always fail closed. +} + +} // namespace ar::iec61850::time_sync diff --git a/include/ariec61850/time_sync/ptp_control_cancellation.hpp b/include/ariec61850/time_sync/ptp_control_cancellation.hpp new file mode 100644 index 000000000..6f7052882 --- /dev/null +++ b/include/ariec61850/time_sync/ptp_control_cancellation.hpp @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include +#include +#include + +namespace ar::iec61850::time_sync { + +// Serialized PTP START/config and asynchronous STOP admission. STOP is +// published immediately even while START is blocking on PTP readiness. +// The current owner MUST drain STOP before releasing its slot. All state +// changes use one atomic word: no lost wakeup between final check and unlock. +class PtpControlCancellationGate final { +public: + [[nodiscard]] bool try_acquire() noexcept { + std::uint8_t expected = 0U; + return state_.compare_exchange_strong(expected, kBusy, + std::memory_order_acq_rel, std::memory_order_acquire); + } + + // Returns true only when the caller owns the idle->STOP transition and + // can immediately drain STOP. When false, an existing owner must drain. + [[nodiscard]] bool request_stop_and_try_acquire() noexcept { + const auto previous = state_.fetch_or( + kStop, std::memory_order_acq_rel); + if ((previous & kBusy) != 0U) return false; + std::uint8_t expected = kStop; + return state_.compare_exchange_strong(expected, + static_cast(kBusy | kStop), + std::memory_order_acq_rel, std::memory_order_acquire); + } + + [[nodiscard]] bool stop_requested() const noexcept { + return (state_.load(std::memory_order_acquire) & kStop) != 0U; + } + + // Only a successful acquisition owner may call finish(). The callback + // is the existing bounded EMAC rollback and runs at most once per + // acquisition. Repeated STOPs during rollback coalesce into that call: + // no new START can enter until the single atomic release. + template + [[nodiscard]] bool finish(StopCallback&& stop_callback) noexcept { + bool cancelled = false; + for (;;) { + const std::uint8_t value = + state_.load(std::memory_order_acquire); + if ((value & kStop) != 0U) { + if (!cancelled) { + cancelled = true; + std::forward(stop_callback)(); + } + std::uint8_t expected = + static_cast(kBusy | kStop); + if (state_.compare_exchange_strong(expected, 0U, + std::memory_order_acq_rel, + std::memory_order_acquire)) return false; + } else { + std::uint8_t expected = kBusy; + if (state_.compare_exchange_strong(expected, 0U, + std::memory_order_acq_rel, + std::memory_order_acquire)) return !cancelled; + } + } + } + +private: + static constexpr std::uint8_t kBusy = 1U; + static constexpr std::uint8_t kStop = 2U; + std::atomic state_{0U}; +}; + +} // namespace ar::iec61850::time_sync diff --git a/include/ariec61850/time_sync/ptp_emac_role_ownership.hpp b/include/ariec61850/time_sync/ptp_emac_role_ownership.hpp new file mode 100644 index 000000000..bdf191fb7 --- /dev/null +++ b/include/ariec61850/time_sync/ptp_emac_role_ownership.hpp @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +namespace ar::iec61850::time_sync { + +// Runtime ownership, not measured LOCK/READY. The EMAC PTP timestamp path +// is exclusive: neither role may take it while the other worker is starting, +// waiting for measured evidence, in holdover, or cleaning up after STOP. +struct PtpEmacRoleOwnership final { + bool source_worker_active{}; + bool receiver_worker_active{}; + bool receiver_cleanup_pending{}; +}; + +[[nodiscard]] constexpr bool can_change_ptp_emac_role( + const PtpEmacRoleOwnership owners) noexcept { + return !owners.source_worker_active && + !owners.receiver_worker_active && + !owners.receiver_cleanup_pending; +} + +} // namespace ar::iec61850::time_sync diff --git a/include/ariec61850/time_sync/ptp_power_profile.hpp b/include/ariec61850/time_sync/ptp_power_profile.hpp new file mode 100644 index 000000000..9e36ad2e4 --- /dev/null +++ b/include/ariec61850/time_sync/ptp_power_profile.hpp @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/time_sync/ptp_runtime.hpp" + +#include + +namespace ar::iec61850::time_sync { + +// SOURCE health is NOT a lifetime frame counter: following ANY TX failure, +// Announce and a *complete* Sync/Follow_Up pair must both be freshly sent. +// Successful Pdelay alone cannot requalify PTP SOURCE for the SV phase servo. +// This fixed-size policy has no platform/timebase assumptions. +class PtpSourceReadinessGate final { +public: + constexpr void invalidate() noexcept { + fresh_announce_ = false; + fresh_sync_pair_ = false; + } + + [[nodiscard]] constexpr bool ready() const noexcept { + return fresh_announce_ && fresh_sync_pair_; + } + + constexpr void observe_announce(const bool delivered) noexcept { + if (!delivered) { + invalidate(); + } else { + fresh_announce_ = true; + } + } + + constexpr void observe_sync_pair(const bool delivered) noexcept { + if (!delivered) { + invalidate(); + } else { + fresh_sync_pair_ = true; + } + } + + constexpr void observe_peer_delay(const bool delivered) noexcept { + if (!delivered) invalidate(); + } + +private: + bool fresh_announce_{}; + bool fresh_sync_pair_{}; +}; + +// A live Ethernet SOURCE is not healthy merely because Announce and Sync were +// successfully transmitted once. Require BOTH recent on-wire observations; +// every input is in the same monotonic microsecond timebase. This portable +// check is also used by the exposed SOURCE status, so stalled FreeRTOS +// scheduling cannot leave a stale READY boolean visible to SV phase control. +struct PtpSourceEvidenceFreshness final { + [[nodiscard]] static constexpr bool fresh( + const std::int64_t now_us, const std::int64_t announce_us, + const std::int64_t sync_follow_up_us, + const std::int64_t timeout_us) noexcept { + if (now_us <= 0 || timeout_us <= 0 || + announce_us <= 0 || sync_follow_up_us <= 0 || + now_us < announce_us || now_us < sync_follow_up_us) { + return false; + } + return now_us - announce_us < timeout_us && + now_us - sync_follow_up_us < timeout_us; + } +}; + +// A plausible RTC and a configurable UTC offset do not by themselves prove +// the TAI/PTP timescale. Only set the timescale AND offset-valid flags when +// the EMAC was actually seeded and the offset was explicitly verified. +// No option here asserts time/frequency traceability or GM accuracy. +[[nodiscard]] constexpr PtpTimeProperties conservative_lab_time_properties( + const bool hardware_tai_seeded, + const bool verified_tai_utc_offset) noexcept { + PtpTimeProperties result{}; + result.ptp_timescale = hardware_tai_seeded && verified_tai_utc_offset; + result.current_utc_offset_valid = result.ptp_timescale; + return result; +} + +// Static configuration compatibility, NOT a declaration of IEEE/IEC +// conformance, grandmaster accuracy, or IED time lock. Runtime hardware +// timestamps, clock quality and Pdelay exchanges need independent evidence. +struct PtpPowerProfileCadence final { + bool announce_1hz{}; + bool sync_1hz{}; + bool peer_delay_supported{}; + + [[nodiscard]] constexpr bool compatible() const noexcept { + return announce_1hz && sync_1hz && peer_delay_supported; + } +}; + +// IEC/IEEE 61850-9-3:2016 defaults for a singly-attached L2/P2P ordinary +// clock: logAnnounceInterval=0, logSyncInterval=0; Pdelay_Req=1s is the +// receiver's own setting. This function checks only publisher-exposed facts. +// Domain and VLAN are deliberately NOT hardcoded: they must match the network. +[[nodiscard]] constexpr PtpPowerProfileCadence inspect_ptp_power_profile_cadence( + const PtpPublisherOptions& options) noexcept { + using namespace std::chrono_literals; + return { + options.announce_interval == 1000ms, + options.sync_interval == 1000ms, + options.respond_to_peer_delay, + }; +} + +} // namespace ar::iec61850::time_sync diff --git a/include/ariec61850/time_sync/ptp_receiver_clock_authority.hpp b/include/ariec61850/time_sync/ptp_receiver_clock_authority.hpp new file mode 100644 index 000000000..ff1f26335 --- /dev/null +++ b/include/ariec61850/time_sync/ptp_receiver_clock_authority.hpp @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include +#include + +namespace ar::iec61850::time_sync { + +// Portable EMAC clock evidence only. Never implies SV GPTimer or PPS phase. +struct PtpReceiverClockObservation final { + bool runtime_ready{}; + bool source_selected{}; + std::array source_clock_identity{}; + std::uint16_t source_port_number{}; + bool discipline_locked{}; + bool clock_discontinuity{}; +}; + +struct PtpReceiverClockDecision final { + // A lease is reusable only when BOTH generation and usable agree. + std::uint32_t generation{}; + bool clock_usable{}; +}; + +// Worker-owned, allocation-free clock lease gate. Phase steps, GM switches +// and actuation failures revoke a still-LOCKED clock until unlock/relock. +class PtpReceiverClockAuthority final { +public: + [[nodiscard]] PtpReceiverClockDecision observe( + const PtpReceiverClockObservation& input) noexcept { + const bool selected = input.runtime_ready && input.source_selected; + const bool locked = selected && input.discipline_locked; + const bool identity_changed = + selected && (!prior_selected_ || + input.source_clock_identity != prior_identity_ || + input.source_port_number != prior_port_); + const bool state_changed = + (initialized_ ? (prior_running_ != input.runtime_ready || + prior_selected_ != selected || + prior_locked_ != locked) + : input.runtime_ready) || + identity_changed; + if (state_changed || input.clock_discontinuity) { + ++generation_; + if (generation_ == 0U) ++generation_; // zero is never admitted + } + if (identity_changed || input.clock_discontinuity) blocked_ = true; + // An *observed* unlocked state precedes all renewed lock admission. + if (!locked && !input.clock_discontinuity) blocked_ = false; + initialized_ = true; + prior_running_ = input.runtime_ready; + prior_selected_ = selected; + prior_locked_ = locked; + prior_identity_ = input.source_clock_identity; + prior_port_ = input.source_port_number; + return {generation_, locked && !blocked_}; + } +private: + std::uint32_t generation_{}; + bool initialized_{}; + bool prior_running_{}; + bool prior_selected_{}; + bool prior_locked_{}; + bool blocked_{}; + std::array prior_identity_{}; + std::uint16_t prior_port_{}; +}; + +} // namespace ar::iec61850::time_sync diff --git a/include/ariec61850/time_sync/ptp_runtime.hpp b/include/ariec61850/time_sync/ptp_runtime.hpp index e6b2b18e4..51e0a2554 100644 --- a/include/ariec61850/time_sync/ptp_runtime.hpp +++ b/include/ariec61850/time_sync/ptp_runtime.hpp @@ -99,9 +99,10 @@ struct PtpPublisherOptions final { PtpClockIdentity clock_identity{0x02U, 0x00U, 0x00U, 0xFFU, 0xFEU, 0x00U, 0x00U, 0x01U}; std::uint16_t port_number{1U}; std::chrono::milliseconds announce_interval{1000}; - std::chrono::milliseconds sync_interval{250}; + std::chrono::milliseconds sync_interval{1000}; std::chrono::milliseconds follow_up_delay{}; bool two_step_clock{true}; + PtpTimeProperties time_properties{}; bool respond_to_peer_delay{true}; std::uint8_t priority1{128U}; std::uint8_t priority2{128U}; @@ -206,6 +207,11 @@ class PtpPublisherRuntime final { error = "Sync interval must be greater than zero"; return false; } + if (options.time_properties.current_utc_offset_valid && + !options.time_properties.ptp_timescale) { + error = "A valid TAI-UTC offset requires the PTP timescale"; + return false; + } if (options.follow_up_delay < std::chrono::milliseconds::zero()) { error = "Follow-up delay cannot be negative"; return false; @@ -271,11 +277,13 @@ class PtpPublisherRuntime final { if (monotonic_time >= next_announce_) { due.announce = true; - next_announce_ = monotonic_time + options_.announce_interval; + next_announce_ = advance_deadline( + next_announce_, monotonic_time, options_.announce_interval); } if (monotonic_time >= next_sync_) { due.sync = true; - next_sync_ = monotonic_time + options_.sync_interval; + next_sync_ = advance_deadline( + next_sync_, monotonic_time, options_.sync_interval); } return due; } @@ -407,6 +415,17 @@ class PtpPublisherRuntime final { } private: + // Preserve the originally anchored 1Hz power-profile cadence across + // delayed poll()/peer-delay processing. O(1), never emit catch-up bursts. + // Sleep/task latency can still affect packet egress and must be measured. + [[nodiscard]] static std::chrono::steady_clock::time_point advance_deadline( + const std::chrono::steady_clock::time_point previous, + const std::chrono::steady_clock::time_point observed, + const std::chrono::milliseconds interval) noexcept { + const auto missed_intervals = (observed - previous) / interval; + return previous + interval * (missed_intervals + 1); + } + [[nodiscard]] static std::int8_t interval_to_log2( const std::chrono::milliseconds interval) noexcept { auto milliseconds = interval.count(); @@ -435,6 +454,7 @@ class PtpPublisherRuntime final { result.sequence_id = sequence_id; result.log_message_interval = log_message_interval; result.two_step = two_step; + result.time_properties = options_.time_properties; result.priority1 = options_.priority1; result.priority2 = options_.priority2; result.clock_class = options_.clock_class; diff --git a/include/ariec61850/time_sync/ptp_tai_source.hpp b/include/ariec61850/time_sync/ptp_tai_source.hpp new file mode 100644 index 000000000..b1b564b04 --- /dev/null +++ b/include/ariec61850/time_sync/ptp_tai_source.hpp @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include + +namespace ar::iec61850::time_sync { + +// A device free-running clock is not a traceable reference. This conversion +// only expresses an already-plausible POSIX/UTC epoch in PTP/TAI seconds; +// it never by itself proves correct UTC, accuracy or leap-second knowledge. +// ESP32-P4 EMAC currently exposes a uint32 seconds register. +[[nodiscard]] constexpr std::optional +try_seed_ptp_tai_seconds(const std::int64_t utc_seconds, + const std::int16_t configured_tai_utc_offset) noexcept { + // Reject unset 1970-era RTC and clearly implausible offset settings. + // A valid date is only a plausibility check, not a trusted time source. + constexpr std::int64_t earliest_supported_utc = 1577836800LL; // 2020-01-01 + if (utc_seconds < earliest_supported_utc || + configured_tai_utc_offset < 0 || configured_tai_utc_offset > 200 || + utc_seconds > static_cast( + std::numeric_limits::max()) - + configured_tai_utc_offset) { + return std::nullopt; + } + return static_cast( + utc_seconds + configured_tai_utc_offset); +} + +} // namespace ar::iec61850::time_sync diff --git a/include/ariec61850/time_sync/sv_ptp_clock_lease.hpp b/include/ariec61850/time_sync/sv_ptp_clock_lease.hpp new file mode 100644 index 000000000..ffd5720e5 --- /dev/null +++ b/include/ariec61850/time_sync/sv_ptp_clock_lease.hpp @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include "ariec61850/time_sync/ptp_canonical_authority.hpp" +#include +#include + +namespace ar::iec61850::time_sync { + +// A source-scoped *clock* lease, not a PPS capture, SV egress certificate, +// or permission to set smpSynch. The role is part of the identity: SOURCE +// generation 3 and RECEIVER generation 3 are never interchangeable. +struct SvPtpClockLease final { + PtpAuthorityRole role{PtpAuthorityRole::monitor}; + std::uint32_t generation{}; + bool clock_usable{}; + bool source_selected{}; + std::array source_clock_identity{}; + std::uint16_t source_port{}; +}; + +// One canonical snapshot is the unit of authority. Never piece together +// generation, source identity, and locked bit from separate status reads. +[[nodiscard]] constexpr bool valid_sv_ptp_clock_lease( + const SvPtpClockLease& lease) noexcept { + if (!lease.clock_usable || lease.generation == 0U) return false; + if (lease.role == PtpAuthorityRole::source) return true; + if (lease.role != PtpAuthorityRole::receiver || + !lease.source_selected || lease.source_port == 0U) return false; + bool nonzero_identity = false; + for (const auto byte : lease.source_clock_identity) { + nonzero_identity = nonzero_identity || byte != 0U; + } + return nonzero_identity; +} + +// An epoch is admissible only while the exact same source-scoped lease is +// observed *before and after* its PTP clock read and at control handoff. +// Loss of LOCKED, GM switch, role swap and step/relock generation always fail. +[[nodiscard]] constexpr bool same_sv_ptp_clock_lease( + const SvPtpClockLease& expected, + const SvPtpClockLease& observed) noexcept { + if (!valid_sv_ptp_clock_lease(expected) || + !valid_sv_ptp_clock_lease(observed) || + expected.role != observed.role || + expected.generation != observed.generation) return false; + if (expected.role == PtpAuthorityRole::source) return true; + return expected.source_selected == observed.source_selected && + expected.source_port == observed.source_port && + expected.source_clock_identity == observed.source_clock_identity; +} + +} // namespace ar::iec61850::time_sync diff --git a/include/ariec61850/time_sync/sv_ptp_epoch.hpp b/include/ariec61850/time_sync/sv_ptp_epoch.hpp new file mode 100644 index 000000000..98978d6bd --- /dev/null +++ b/include/ariec61850/time_sync/sv_ptp_epoch.hpp @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/time_sync/smp_synch_policy.hpp" + +#include +#include + +namespace ar::iec61850::time_sync { + +// A cross-clock observation is obtained OUTSIDE the SV hot path. The PTP +// time read is bracketed by a monotonic clock; half of that span bounds +// the unknown instant at which the EMAC clock was sampled. +struct SvPtpClockObservation final { + std::uint32_t ptp_nanoseconds{}; + std::uint64_t monotonic_before_us{}; + std::uint64_t monotonic_after_us{}; +}; + +struct SvSecondBoundaryArm final { + std::uint32_t first_alarm_us{}; + std::uint32_t clock_read_uncertainty_us{}; +}; + +// Plan only the first aligned sample. Subsequent GPTimer deadlines must be +// verified against the PTP clock: this calculation alone never licenses +// smpSynch=1/2 or makes GPTimer frequency-disciplined. +[[nodiscard]] constexpr std::optional +plan_sv_ptp_second_arm(const SvPtpClockObservation& o, + const std::uint32_t minimum_lead_us = 20000U, + const std::uint32_t maximum_read_span_us = 1000U) noexcept { + constexpr std::uint64_t us_per_second = 1000000ULL; + constexpr std::uint64_t ns_per_second = 1000000000ULL; + if (o.ptp_nanoseconds >= ns_per_second || + o.monotonic_after_us < o.monotonic_before_us || + minimum_lead_us == 0U || minimum_lead_us > us_per_second) { + return std::nullopt; + } + const std::uint64_t read_span_us = + o.monotonic_after_us - o.monotonic_before_us; + if (read_span_us > maximum_read_span_us) return std::nullopt; + // Anchor to the midpoint of the EMAC IOCTL call and advance to the + // monotonic timestamp captured after that call. All math is bounded. + const std::uint64_t estimated_ptp_ns = + (static_cast(o.ptp_nanoseconds) + + ((read_span_us + 1ULL) / 2ULL) * 1000ULL) % ns_per_second; + const std::uint64_t uncertainty_us = (read_span_us + 1ULL) / 2ULL; + const std::uint64_t until_next_us = + (ns_per_second - estimated_ptp_ns + 999ULL) / 1000ULL; + // Leave enough time for GPTimer setup and software latency. Starting + // one epoch later is preferable to a late sample 0. + const std::uint64_t first_us = until_next_us < minimum_lead_us + ? until_next_us + us_per_second + : until_next_us; + if (first_us == 0U || first_us > 2ULL * us_per_second) return std::nullopt; + return SvSecondBoundaryArm{ + static_cast(first_us), + static_cast(uncertainty_us)}; +} + +// Re-anchor first GPTimer alarm to the actual timer START window. +// The PTP IOCTL precedes timer setup: using its original delay unchanged +// introduces setup latency as a systematic phase offset. Reject stale reads. +[[nodiscard]] constexpr std::optional +rebase_sv_ptp_second_arm( + const SvSecondBoundaryArm& planned, + const std::uint64_t clock_read_after_us, + const std::uint64_t timer_start_preparation_us, + const std::uint32_t minimum_start_lead_us = 20000U, + const std::uint32_t maximum_rebase_age_us = 100000U) noexcept { + constexpr std::uint64_t epoch_us = 1000000ULL; + if (timer_start_preparation_us < clock_read_after_us || + minimum_start_lead_us == 0U || + minimum_start_lead_us > epoch_us || + planned.first_alarm_us == 0U || + planned.first_alarm_us > 2ULL * epoch_us) return std::nullopt; + const std::uint64_t age = timer_start_preparation_us - + clock_read_after_us; + if (age > maximum_rebase_age_us) return std::nullopt; + std::uint64_t until_epoch = planned.first_alarm_us; + // Skip to the next whole PTP second rather than start too late. + if (until_epoch <= age + minimum_start_lead_us) until_epoch += epoch_us; + if (until_epoch <= age) return std::nullopt; + const std::uint64_t remaining = until_epoch - age; + if (remaining < minimum_start_lead_us || + remaining > 2ULL * epoch_us) return std::nullopt; + return SvSecondBoundaryArm{ + static_cast(remaining), + planned.clock_read_uncertainty_us}; +} + +// The clock receiver can prove PTP lock, but it cannot independently prove +// the SV publisher's sample phase. AUTO must remain 0 until BOTH domains +// have been qualified. Laboratory overrides remain deliberately separate. +[[nodiscard]] constexpr std::optional +qualified_sv_sync_value(const std::optional ptp_clock_sync, + const bool timeline_phase_qualified) noexcept { + if (!timeline_phase_qualified) return std::nullopt; + return ptp_clock_sync; +} + +} // namespace ar::iec61850::time_sync diff --git a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp new file mode 100644 index 000000000..c0104615f --- /dev/null +++ b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp @@ -0,0 +1,318 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include + +namespace ar::iec61850::time_sync { + +// Portable, allocation-free CONTROL-PLANE authority for the relationship +// between ESP32-P4 GPTimer sample deadlines and the EMAC IEEE1588 PTP epoch. +// It does NOT establish hardware capture accuracy or authorize smpSynch. +// The caller collects a PTP read bracketed by GPTimer raw counters outside +// the ISR, together with the ISR-published last smpCnt=0 *deadline*. +// All timer tick values are 1 MHz and intentionally wrap at 32 bits. +struct SvPtpPhaseObservation final { + std::uint32_t timer_before_ticks{}; + std::uint32_t timer_after_ticks{}; + std::uint32_t last_sv_epoch_ticks{}; + std::uint32_t last_sv_epoch_generation{}; + std::uint64_t ptp_seconds{}; + std::uint32_t ptp_nanoseconds{}; +}; + +enum class SvPtpPhaseState : std::uint8_t { + acquiring, + tracking, + invalid, +}; + +struct SvPtpPhaseDecision final { + SvPtpPhaseState state{SvPtpPhaseState::acquiring}; + bool accepted{}; + bool clock_discontinuity{}; + std::int32_t phase_error_us{}; + std::uint32_t observation_uncertainty_us{}; + std::int32_t slew_ticks_next_epoch{}; + // This is a software timing assessment, NOT a valid wire smpSynch claim. + bool software_tracking{}; + // Stable integer PTP second paired with the last smpCnt=0 deadline. + // A first observation is a baseline; only two consecutive epochs bind. + bool epoch_second_continuous{}; + std::uint64_t sv_epoch_ptp_second{}; +}; + +struct SvPtpPhasePolicy final { + std::uint32_t maximum_clock_read_span_us{500U}; + std::uint32_t maximum_clock_rate_error_us{400U}; + std::uint32_t maximum_observation_spacing_us{1500000U}; + std::int32_t maximum_phase_error_us{20000}; + std::int32_t maximum_slew_per_second_us{400}; + std::int32_t integral_limit_us{250}; + std::int32_t phase_deadband_us{2}; + std::int32_t software_tracking_threshold_us{60}; + std::uint32_t consecutive_tracking_samples{4U}; +}; + +namespace sv_phase_detail { +[[nodiscard]] constexpr std::int64_t signed_delta_u32( + const std::uint32_t newer, const std::uint32_t older) noexcept { + constexpr std::int64_t modulus = 4294967296LL; + const std::uint32_t delta = newer - older; + return delta <= static_cast(std::numeric_limits::max()) + ? static_cast(delta) + : static_cast(delta) - modulus; +} + +[[nodiscard]] constexpr std::int32_t normalize_to_nearest_ptp_second( + const std::int64_t ticks) noexcept { + constexpr std::int64_t second = 1000000LL; + std::int64_t v = (ticks + (second / 2)) % second; + if (v < 0) v += second; + return static_cast(v - (second / 2)); +} + +[[nodiscard]] constexpr std::int64_t magnitude( + const std::int64_t value) noexcept { + return value < 0 ? -value : value; +} +} // namespace sv_phase_detail + +class SvPtpPhaseAuthority final { +public: + explicit constexpr SvPtpPhaseAuthority( + const SvPtpPhasePolicy policy = {}) noexcept : policy_(policy) {} + + constexpr void reset() noexcept { + have_previous_ = false; + previous_sv_epoch_ptp_second_ = 0U; + integral_us_ = 0; + tracking_samples_ = 0U; + state_ = SvPtpPhaseState::acquiring; + } + + // Call after source loss, publisher stop/re-arm, detected clock step, + // missing PTP source, or stale observations. No stale steering survives. + [[nodiscard]] constexpr SvPtpPhaseDecision invalidate() noexcept { + reset(); + state_ = SvPtpPhaseState::invalid; + return {state_, false, true, 0, 0U, 0, false, false, 0U}; + } + + // One well-bracketed observation per approximately 1-second SV epoch. + // Wrong epoch generation, PTP steps and implausible clock drift are + // rejected before generating any correction. The signed correction is + // distributed by SvBoundedSlewCursor over the NEXT complete epoch. + [[nodiscard]] constexpr SvPtpPhaseDecision observe( + const SvPtpPhaseObservation o) noexcept { + constexpr std::uint32_t ns_per_second = 1000000000U; + if (o.ptp_seconds == 0U || o.ptp_nanoseconds >= ns_per_second || + o.last_sv_epoch_generation == 0U) { + return invalidate(); + } + const std::uint32_t span = o.timer_after_ticks - o.timer_before_ticks; + if (span > policy_.maximum_clock_read_span_us) return invalidate(); + const std::uint32_t midpoint = o.timer_before_ticks + (span / 2U); + // There must have been a real ISR epoch reasonably recently. + const std::uint32_t epoch_age = midpoint - o.last_sv_epoch_ticks; + if (epoch_age > 1100000U) return invalidate(); + + const std::int64_t raw_phase = + sv_phase_detail::signed_delta_u32(o.last_sv_epoch_ticks, midpoint) + + static_cast(o.ptp_nanoseconds / 1000U); + // Do not discard the integer-second label by modulo reduction. + // The bounded signed shift also handles readings just before a PTP + // second boundary; GPTimer low32 can wrap independently. + constexpr std::int64_t second_us = 1000000LL; + const std::int64_t shift = raw_phase >= 0 + ? (raw_phase + second_us / 2) / second_us + : (raw_phase - second_us / 2) / second_us; + if (shift < -1 || shift > 1 || + (shift < 0 && o.ptp_seconds == 0U) || + (shift > 0 && o.ptp_seconds == + std::numeric_limits::max())) { + return invalidate(); + } + const std::uint64_t sv_epoch_second = shift < 0 + ? o.ptp_seconds - 1U + : o.ptp_seconds + static_cast(shift); + const std::int32_t phase = static_cast( + raw_phase - shift * second_us); + const std::uint32_t uncertainty = ((span + 1U) / 2U) + 1U; + if (sv_phase_detail::magnitude(phase) > + policy_.maximum_phase_error_us) { + return invalidate(); + } + + if (have_previous_) { + const std::uint32_t epoch_advance = + o.last_sv_epoch_generation - previous_epoch_generation_; + if (epoch_advance != 1U || + sv_epoch_second <= previous_sv_epoch_ptp_second_ || + sv_epoch_second - previous_sv_epoch_ptp_second_ != 1U || + o.ptp_seconds < previous_ptp_seconds_ || + o.ptp_seconds - previous_ptp_seconds_ > 2U) { + return invalidate(); + } + const std::int64_t ptp_ns = + static_cast( + o.ptp_seconds - previous_ptp_seconds_) * ns_per_second + + static_cast(o.ptp_nanoseconds) - + static_cast(previous_ptp_nanoseconds_); + if (ptp_ns <= 0LL || + ptp_ns > static_cast( + policy_.maximum_observation_spacing_us) * 1000LL) { + return invalidate(); + } + const std::uint32_t timer_delta = midpoint - previous_midpoint_; + const std::int64_t ptp_elapsed_us = (ptp_ns + 500LL) / 1000LL; + if (sv_phase_detail::magnitude( + static_cast(timer_delta) - ptp_elapsed_us) > + policy_.maximum_clock_rate_error_us) { + return invalidate(); + } + } + + previous_midpoint_ = midpoint; + previous_ptp_seconds_ = o.ptp_seconds; + previous_ptp_nanoseconds_ = o.ptp_nanoseconds; + previous_epoch_generation_ = o.last_sv_epoch_generation; + previous_sv_epoch_ptp_second_ = sv_epoch_second; + const bool first_observation = !have_previous_; + have_previous_ = true; + + // A small nominal phase residual is NOT sufficiently qualified + // when the GPTimer/PTP read bracket is wider than the tracking + // tolerance. Include the full bracket uncertainty before advancing + // consecutive software-tracking samples (never wire smpSynch). + const std::int64_t worst_case_phase_us = + sv_phase_detail::magnitude(phase) + + static_cast(uncertainty); + if (worst_case_phase_us <= + policy_.software_tracking_threshold_us) { + if (tracking_samples_ < policy_.consecutive_tracking_samples) { + ++tracking_samples_; + } + } else { + tracking_samples_ = 0U; + } + state_ = tracking_samples_ >= policy_.consecutive_tracking_samples + ? SvPtpPhaseState::tracking : SvPtpPhaseState::acquiring; + + if (first_observation) { + return {state_, true, false, phase, uncertainty, 0, false, + false, sv_epoch_second}; + } + + // Bounded PI servo. The integrator estimates fixed frequency bias, + // while the proportional term steers phase. All state is fixed-size, + // and control is run at ~1 Hz outside the realtime publisher. + const std::int32_t input = + sv_phase_detail::magnitude(phase) <= policy_.phase_deadband_us + ? 0 : phase; + integral_us_ = std::clamp( + integral_us_ + (input / 4), + -policy_.integral_limit_us, policy_.integral_limit_us); + const std::int32_t requested = std::clamp( + -input - integral_us_, + -policy_.maximum_slew_per_second_us, + policy_.maximum_slew_per_second_us); + return {state_, true, false, phase, uncertainty, requested, + state_ == SvPtpPhaseState::tracking, true, sv_epoch_second}; + } + +private: + SvPtpPhasePolicy policy_{}; + bool have_previous_{}; + std::uint32_t previous_midpoint_{}; + std::uint32_t previous_ptp_nanoseconds_{}; + std::uint32_t previous_epoch_generation_{}; + std::uint64_t previous_ptp_seconds_{}; + std::uint64_t previous_sv_epoch_ptp_second_{}; + std::int32_t integral_us_{}; + std::uint32_t tracking_samples_{}; + SvPtpPhaseState state_{SvPtpPhaseState::acquiring}; +}; + +// A control-plane gate for evidence that a canonical SV publisher has not +// skipped sample slots or failed Ethernet TX since the previous observation. +// A fresh session and any fault require re-acquisition; neither can inherit +// a previously qualified phase controller. This does NOT authorize smpSynch. +class SvPtpPublisherContinuityGate final { +public: + constexpr void reset() noexcept { + session_ = 0U; + fault_generation_ = 0U; + source_generation_ = 0U; + } + + [[nodiscard]] constexpr bool unchanged( + const std::uint32_t session, const std::uint32_t fault_generation, + const std::uint32_t source_generation = 0U) noexcept { + if (session == 0U) { + reset(); + return false; + } + if (session_ != session || fault_generation_ != fault_generation || + source_generation_ != source_generation) { + session_ = session; + fault_generation_ = fault_generation; + source_generation_ = source_generation; + return false; + } + return true; + } + +private: + std::uint32_t session_{}; + std::uint32_t fault_generation_{}; + std::uint32_t source_generation_{}; +}; + +// ISR-safe, O(1), no allocation/locks/IOCTL. Each interval differs from the +// existing rational scheduler by at most ONE 1-MHz timer tick. The complete +// correction is distributed across one explicitly armed second (4000/4800 +// frames, not inferred universally from a sample-counter modulus). +class SvBoundedSlewCursor final { +public: + constexpr void arm(const std::uint32_t intervals_per_second, + const std::int32_t requested_ticks) noexcept { + rate_ = intervals_per_second; + if (rate_ == 0U) { + correction_ = 0; + } else { + const std::int32_t max_ticks = static_cast( + std::min(rate_, 400U)); + correction_ = std::clamp(requested_ticks, -max_ticks, max_ticks); + } + accumulator_ = 0U; + applied_ = 0U; + } + + [[nodiscard]] constexpr std::int32_t next_adjustment() noexcept { + if (rate_ == 0U || correction_ == 0) return 0; + const std::uint32_t amount = correction_ < 0 + ? static_cast(-correction_) + : static_cast(correction_); + accumulator_ += amount; + if (accumulator_ >= rate_) { + accumulator_ -= rate_; + applied_ += correction_ < 0 ? -1 : 1; + return correction_ < 0 ? -1 : 1; + } + return 0; + } + + [[nodiscard]] constexpr std::int32_t applied_ticks() const noexcept { + return applied_; + } + +private: + std::uint32_t rate_{}; + std::uint32_t accumulator_{}; + std::int32_t correction_{}; + std::int32_t applied_{}; +}; + +} // namespace ar::iec61850::time_sync diff --git a/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp new file mode 100644 index 000000000..fc1525e0a --- /dev/null +++ b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include + +namespace ar::iec61850::time_sync { + +// Sparse, read-only MAC descriptor TX evidence. No result authorizes +// smpSynch=1/2, grandmaster quality or IED synchronization. +struct SvTxEgressObservation final { + std::uint16_t smp_cnt{}; + std::uint32_t sample_epoch_generation{}; + std::uint32_t publisher_session_generation{}; + std::uint32_t publisher_fault_generation{}; + std::uint32_t ptp_source_generation{}; + std::uint32_t scheduled_alarm_ticks_low32{}; + std::uint32_t hw_ptp_seconds{}; + std::uint32_t hw_ptp_nanoseconds{}; + std::uint32_t submit_duration_us{}; + bool canonical_tx_success{}; + bool no_missed_slots{}; + bool same_sample_epoch{}; +}; + +enum class SvTxEvidenceResult : std::uint8_t { + measured, // two consecutive coherent sparse epochs + baseline_established, // first observation or controlled reacquisition + not_a_qualified_epoch, + transmit_failed, + missing_hw_timestamp, + publisher_discontinuity, // stop/restart/fault or missed epoch sequence + ptp_clock_discontinuity, // hardware PTP time stepped/skipped seconds +}; + +struct SvTxEgressSummary final { + std::uint32_t attempted{}; + std::uint32_t baseline_established{}; + std::uint32_t measured{}; + std::uint32_t rejected_epoch{}; + std::uint32_t failed_transmit{}; + std::uint32_t missing_hw_timestamp{}; + std::uint32_t publisher_discontinuities{}; + std::uint32_t ptp_clock_discontinuities{}; + std::uint32_t ptp_source_changes{}; + std::int32_t min_phase_ns{std::numeric_limits::max()}; + std::int32_t max_phase_ns{std::numeric_limits::min()}; + std::int64_t phase_sum_ns{}; + std::uint32_t max_submit_duration_us{}; + std::uint32_t last_ptp_seconds{}; + std::uint32_t last_alarm_ticks_low32{}; + std::uint32_t last_epoch_generation{}; + std::int32_t last_phase_ns{}; +}; + +// Explicit stride corresponds to every 16th sample-counter-zero epoch in +// ESP32-P4 M2C. Readings are NOT inferred from packet rate or PCAP timestamps. +// No heap, locks, I/O or clocks; suitable for strict portable unit testing. +class SvTxEgressEvidence final { +public: + explicit constexpr SvTxEgressEvidence( + const std::uint32_t expected_epoch_stride = 16U) noexcept + : expected_stride_(expected_epoch_stride) {} + + constexpr void reset() noexcept { + summary_ = {}; + invalidate_baseline(); + } + + [[nodiscard]] constexpr SvTxEvidenceResult record( + const SvTxEgressObservation& o) noexcept { + ++summary_.attempted; + summary_.max_submit_duration_us = + std::max(summary_.max_submit_duration_us, o.submit_duration_us); + if (!o.canonical_tx_success) { + ++summary_.failed_transmit; + invalidate_baseline(); + return SvTxEvidenceResult::transmit_failed; + } + if (o.smp_cnt != 0U || o.sample_epoch_generation == 0U || + o.publisher_session_generation == 0U || + o.ptp_source_generation == 0U || + !o.no_missed_slots || !o.same_sample_epoch || + expected_stride_ == 0U) { + ++summary_.rejected_epoch; + invalidate_baseline(); + return SvTxEvidenceResult::not_a_qualified_epoch; + } + if (o.hw_ptp_seconds == 0U || + o.hw_ptp_nanoseconds >= 1'000'000'000U) { + ++summary_.missing_hw_timestamp; + invalidate_baseline(); + return SvTxEvidenceResult::missing_hw_timestamp; + } + + // At >0.5s we are closest to the NEXT whole-second boundary. + // unsigned 64-bit prevents unintended wrap of the driver 32-bit field. + const std::uint64_t nearest_ptp_second = + static_cast(o.hw_ptp_seconds) + + (o.hw_ptp_nanoseconds >= 500'000'000U ? 1U : 0U); + const std::int32_t phase_ns = o.hw_ptp_nanoseconds < 500'000'000U + ? static_cast(o.hw_ptp_nanoseconds) + : static_cast( + static_cast(o.hw_ptp_nanoseconds) - + 1'000'000'000LL); + + if (!have_baseline_) { + remember(o, nearest_ptp_second); + ++summary_.baseline_established; + return SvTxEvidenceResult::baseline_established; + } + const bool publisher_continuous = + o.publisher_session_generation == previous_session_ && + o.publisher_fault_generation == previous_fault_generation_ && + o.sample_epoch_generation - previous_epoch_generation_ == + expected_stride_; + if (!publisher_continuous) { + ++summary_.publisher_discontinuities; + remember(o, nearest_ptp_second); // reacquire; do not claim phase + ++summary_.baseline_established; + return SvTxEvidenceResult::publisher_discontinuity; + } + if (o.ptp_source_generation != previous_ptp_source_generation_) { + ++summary_.ptp_source_changes; + remember(o, nearest_ptp_second); + ++summary_.baseline_established; + return SvTxEvidenceResult::ptp_clock_discontinuity; + } + if (nearest_ptp_second <= previous_nearest_ptp_second_ || + nearest_ptp_second - previous_nearest_ptp_second_ != + static_cast(expected_stride_)) { + ++summary_.ptp_clock_discontinuities; + remember(o, nearest_ptp_second); // rebase after clock step + ++summary_.baseline_established; + return SvTxEvidenceResult::ptp_clock_discontinuity; + } + + remember(o, nearest_ptp_second); + ++summary_.measured; + summary_.min_phase_ns = std::min(summary_.min_phase_ns, phase_ns); + summary_.max_phase_ns = std::max(summary_.max_phase_ns, phase_ns); + summary_.phase_sum_ns += phase_ns; + summary_.last_ptp_seconds = o.hw_ptp_seconds; + summary_.last_alarm_ticks_low32 = o.scheduled_alarm_ticks_low32; + summary_.last_epoch_generation = o.sample_epoch_generation; + summary_.last_phase_ns = phase_ns; + return SvTxEvidenceResult::measured; + } + + [[nodiscard]] constexpr const SvTxEgressSummary& summary() const noexcept { + return summary_; + } +private: + constexpr void invalidate_baseline() noexcept { + have_baseline_ = false; + previous_nearest_ptp_second_ = 0U; + previous_epoch_generation_ = 0U; + previous_session_ = 0U; + previous_fault_generation_ = 0U; + previous_ptp_source_generation_ = 0U; + } + constexpr void remember(const SvTxEgressObservation& o, + const std::uint64_t nearest) noexcept { + have_baseline_ = true; + previous_nearest_ptp_second_ = nearest; + previous_epoch_generation_ = o.sample_epoch_generation; + previous_session_ = o.publisher_session_generation; + previous_fault_generation_ = o.publisher_fault_generation; + previous_ptp_source_generation_ = o.ptp_source_generation; + } + + std::uint32_t expected_stride_{16U}; + bool have_baseline_{}; + std::uint64_t previous_nearest_ptp_second_{}; + std::uint32_t previous_epoch_generation_{}; + std::uint32_t previous_session_{}; + std::uint32_t previous_fault_generation_{}; + std::uint32_t previous_ptp_source_generation_{}; + SvTxEgressSummary summary_{}; +}; + +// Sparse MAC egress is a corroborating measurement, NOT sufficient by itself +// to declare synchronized samples. The independent software phase observer +// must ALSO verify smpCnt-zero PTP epoch continuity with a bounded clock-read +// uncertainty. Keep this gate independent of ESP-IDF/Qt for fault regression. +class SvLocalSyncEvidenceGate final { +public: + static constexpr std::int64_t kMaxEgressPhaseNs = 4000LL; + static constexpr std::uint32_t kMaxSoftwarePhaseBoundUs = 4U; + static constexpr std::uint32_t kEvidenceStride = 16U; + static constexpr std::uint32_t kMaxEvidenceAgeEpochs = 17U; + static constexpr std::uint32_t kRequiredMeasuredPairs = 2U; + + constexpr void reset() noexcept { + matched_pairs_ = 0U; + session_ = 0U; + fault_generation_ = 0U; + source_generation_ = 0U; + last_epoch_ = 0U; + } + + // Call only for the actual canonical SV descriptor timestamp. + // The lower-level tracker already verifies that consecutive timestamps + // belong to adjacent 16-second PTP epochs and the same source/session. + constexpr void observe(const SvTxEgressObservation& o, + const SvTxEvidenceResult result, + const std::int32_t phase_ns) noexcept { + const std::int64_t phase = static_cast(phase_ns); + if (result != SvTxEvidenceResult::measured || + !o.canonical_tx_success || !o.no_missed_slots || + !o.same_sample_epoch || + phase > kMaxEgressPhaseNs || phase < -kMaxEgressPhaseNs) { + reset(); + return; + } + if (matched_pairs_ != 0U && + (session_ != o.publisher_session_generation || + fault_generation_ != o.publisher_fault_generation || + source_generation_ != o.ptp_source_generation || + o.sample_epoch_generation - last_epoch_ != kEvidenceStride)) { + reset(); + } + session_ = o.publisher_session_generation; + fault_generation_ = o.publisher_fault_generation; + source_generation_ = o.ptp_source_generation; + last_epoch_ = o.sample_epoch_generation; + if (matched_pairs_ < kRequiredMeasuredPairs) ++matched_pairs_; + } + + [[nodiscard]] constexpr bool qualified( + const std::uint32_t session, + const std::uint32_t fault_generation, + const std::uint32_t source_generation, + const std::uint32_t current_epoch, + const bool software_tracking, + const std::uint32_t software_phase_bound_us) const noexcept { + return matched_pairs_ >= kRequiredMeasuredPairs && + software_tracking && + software_phase_bound_us <= kMaxSoftwarePhaseBoundUs && + session != 0U && source_generation != 0U && + session == session_ && + fault_generation == fault_generation_ && + source_generation == source_generation_ && + current_epoch >= last_epoch_ && + current_epoch - last_epoch_ <= kMaxEvidenceAgeEpochs; + } +private: + std::uint32_t matched_pairs_{}; + std::uint32_t session_{}; + std::uint32_t fault_generation_{}; + std::uint32_t source_generation_{}; + std::uint32_t last_epoch_{}; +}; + +} // namespace ar::iec61850::time_sync diff --git a/src/capture/pcap.cpp b/src/capture/pcap.cpp index b8f130006..47f6309ba 100644 --- a/src/capture/pcap.cpp +++ b/src/capture/pcap.cpp @@ -6,6 +6,7 @@ #include #include #include +#include namespace ar::iec61850::capture { namespace { @@ -108,6 +109,27 @@ std::vector PcapReader::read_all(const std::filesystem::path& file_p } std::vector PcapReader::read_all(std::istream& stream) { + std::vector packets; + for_each_packet(stream, [&packets](PcapPacket&& packet) { + packets.push_back(std::move(packet)); + }); + return packets; +} + +void PcapReader::for_each_packet( + const std::filesystem::path& file_path, const PacketVisitor& visitor) { + std::ifstream stream(file_path, std::ios::binary); + if (!stream) { + throw PcapFormatError("Unable to open PCAP file: " + file_path.string()); + } + for_each_packet(stream, visitor); +} + +void PcapReader::for_each_packet( + std::istream& stream, const PacketVisitor& visitor) { + if (!visitor) { + throw std::invalid_argument("PCAP packet visitor must not be empty."); + } std::array global_header{}; if (!read_exactly(stream, global_header)) { throw PcapFormatError("PCAP file is shorter than the global header."); @@ -135,7 +157,6 @@ std::vector PcapReader::read_all(std::istream& stream) { std::to_string(snap_length) + "."); } - std::vector packets; while (true) { std::array packet_header{}; stream.read(reinterpret_cast(packet_header.data()), @@ -168,10 +189,9 @@ std::vector PcapReader::read_all(std::istream& stream) { if (!read_exactly(stream, frame)) { throw PcapFormatError("Truncated PCAP packet payload."); } - packets.push_back({to_timestamp(seconds, fractional, resolution), std::move(frame)}); + visitor(PcapPacket{to_timestamp(seconds, fractional, resolution), std::move(frame)}); } - return packets; } PcapWriter::PcapWriter(std::ostream& stream, const std::uint32_t snap_length) diff --git a/src/time_sync/ptp.cpp b/src/time_sync/ptp.cpp index d4d5331a0..3909b1391 100644 --- a/src/time_sync/ptp.cpp +++ b/src/time_sync/ptp.cpp @@ -74,7 +74,8 @@ void write_u64_be(const std::span destination, const std::uint64_t 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_u16_be(span.subspan(6U, 2U), static_cast( + (options.two_step ? 0x0200U : 0U) | options.time_properties.flags())); write_u64_be(span.subspan(8U, 8U), static_cast(options.correction_field)); std::copy( options.source_port_identity.clock_identity.begin(), diff --git a/tests/ptp_profile/CMakeLists.txt b/tests/ptp_profile/CMakeLists.txt index 076aecfaa..3ecf941b2 100644 --- a/tests/ptp_profile/CMakeLists.txt +++ b/tests/ptp_profile/CMakeLists.txt @@ -19,6 +19,11 @@ add_executable(ptp_profile_tests target_link_libraries(ptp_profile_tests PRIVATE ptp_profile_core) target_compile_features(ptp_profile_tests PRIVATE cxx_std_20) +add_executable(ptp_power_profile_tests + "${ARSTACK_ROOT}/tests/test_ptp_power_profile.cpp") +target_link_libraries(ptp_power_profile_tests PRIVATE ptp_profile_core) +target_compile_features(ptp_power_profile_tests PRIVATE cxx_std_20) + add_executable(ptp_runtime_tests "${ARSTACK_ROOT}/tests/test_ptp_runtime.cpp") target_link_libraries(ptp_runtime_tests PRIVATE ptp_profile_core) @@ -39,6 +44,46 @@ add_executable(ptp_discipline_tests target_link_libraries(ptp_discipline_tests PRIVATE ptp_profile_core) target_compile_features(ptp_discipline_tests PRIVATE cxx_std_20) +add_executable(ptp_control_cancellation_tests + "${ARSTACK_ROOT}/tests/test_ptp_control_cancellation.cpp") +target_link_libraries(ptp_control_cancellation_tests PRIVATE ptp_profile_core) +target_compile_features(ptp_control_cancellation_tests PRIVATE cxx_std_20) + +add_executable(ptp_emac_role_ownership_tests + "${ARSTACK_ROOT}/tests/test_ptp_emac_role_ownership.cpp") +target_link_libraries(ptp_emac_role_ownership_tests PRIVATE ptp_profile_core) +target_compile_features(ptp_emac_role_ownership_tests PRIVATE cxx_std_20) + +add_executable(ptp_canonical_authority_tests + "${ARSTACK_ROOT}/tests/test_ptp_canonical_authority.cpp") +target_link_libraries(ptp_canonical_authority_tests PRIVATE ptp_profile_core) +target_compile_features(ptp_canonical_authority_tests PRIVATE cxx_std_20) + +add_executable(ptp_receiver_clock_authority_tests + "${ARSTACK_ROOT}/tests/test_ptp_receiver_clock_authority.cpp") +target_link_libraries(ptp_receiver_clock_authority_tests PRIVATE ptp_profile_core) +target_compile_features(ptp_receiver_clock_authority_tests PRIVATE cxx_std_20) + +add_executable(sv_ptp_clock_lease_tests + "${ARSTACK_ROOT}/tests/test_sv_ptp_clock_lease.cpp") +target_link_libraries(sv_ptp_clock_lease_tests PRIVATE ptp_profile_core) +target_compile_features(sv_ptp_clock_lease_tests PRIVATE cxx_std_20) + +add_executable(sv_ptp_phase_authority_tests + "${ARSTACK_ROOT}/tests/test_sv_ptp_phase_authority.cpp") +target_link_libraries(sv_ptp_phase_authority_tests PRIVATE ptp_profile_core) +target_compile_features(sv_ptp_phase_authority_tests PRIVATE cxx_std_20) + +add_executable(sv_tx_egress_evidence_tests + "${ARSTACK_ROOT}/tests/test_sv_tx_egress_evidence.cpp") +target_link_libraries(sv_tx_egress_evidence_tests PRIVATE ptp_profile_core) +target_compile_features(sv_tx_egress_evidence_tests PRIVATE cxx_std_20) + +add_executable(sv_ptp_epoch_tests + "${ARSTACK_ROOT}/tests/test_sv_ptp_epoch.cpp") +target_link_libraries(sv_ptp_epoch_tests PRIVATE ptp_profile_core) +target_compile_features(sv_ptp_epoch_tests PRIVATE cxx_std_20) + add_executable(ptp_p2_hardening_tests "${ARSTACK_ROOT}/tests/test_ptp_p2_hardening.cpp") target_link_libraries(ptp_p2_hardening_tests PRIVATE ptp_profile_core) @@ -49,7 +94,7 @@ add_executable(ptp_profile_analyze 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_runtime_tests smp_synch_policy_tests smp_synch_wire_offset_tests ptp_discipline_tests ptp_p2_hardening_tests ptp_profile_analyze) +foreach(target IN ITEMS ptp_profile_core ptp_profile_tests ptp_power_profile_tests ptp_runtime_tests smp_synch_policy_tests smp_synch_wire_offset_tests ptp_discipline_tests sv_ptp_epoch_tests ptp_control_cancellation_tests ptp_emac_role_ownership_tests ptp_canonical_authority_tests ptp_receiver_clock_authority_tests sv_ptp_clock_lease_tests sv_ptp_phase_authority_tests sv_tx_egress_evidence_tests ptp_p2_hardening_tests ptp_profile_analyze) if(MSVC) target_compile_options(${target} PRIVATE /W4 /WX /permissive-) else() @@ -60,9 +105,18 @@ endforeach() enable_testing() add_test(NAME ptp_profile_tests COMMAND ptp_profile_tests) +add_test(NAME ptp_power_profile_tests COMMAND ptp_power_profile_tests) add_test(NAME ptp_runtime_tests COMMAND ptp_runtime_tests) add_test(NAME smp_synch_policy_tests COMMAND smp_synch_policy_tests) add_test(NAME smp_synch_wire_offset_tests COMMAND smp_synch_wire_offset_tests) add_test(NAME ptp_discipline_tests COMMAND ptp_discipline_tests) +add_test(NAME sv_ptp_epoch_tests COMMAND sv_ptp_epoch_tests) +add_test(NAME ptp_control_cancellation_tests COMMAND ptp_control_cancellation_tests) +add_test(NAME ptp_emac_role_ownership_tests COMMAND ptp_emac_role_ownership_tests) +add_test(NAME ptp_canonical_authority_tests COMMAND ptp_canonical_authority_tests) +add_test(NAME ptp_receiver_clock_authority_tests COMMAND ptp_receiver_clock_authority_tests) +add_test(NAME sv_ptp_clock_lease_tests COMMAND sv_ptp_clock_lease_tests) +add_test(NAME sv_ptp_phase_authority_tests COMMAND sv_ptp_phase_authority_tests) +add_test(NAME sv_tx_egress_evidence_tests COMMAND sv_tx_egress_evidence_tests) add_test(NAME ptp_p2_hardening_tests COMMAND ptp_p2_hardening_tests) add_test(NAME ptp_profile_analyze_help COMMAND ptp_profile_analyze --help) diff --git a/tests/test_ptp_canonical_authority.cpp b/tests/test_ptp_canonical_authority.cpp new file mode 100644 index 000000000..b28ca061e --- /dev/null +++ b/tests/test_ptp_canonical_authority.cpp @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/time_sync/ptp_canonical_authority.hpp" +#include +#include + +using ar::iec61850::time_sync::PtpAuthorityRole; +using ar::iec61850::time_sync::PtpCanonicalAuthorityEvidence; +using ar::iec61850::time_sync::canonical_ptp_authority; +#define CHECK(x) do { if (!(x)) { std::fprintf(stderr,"FAIL %d: %s\n",__LINE__,#x); std::abort(); } } while(false) + +int main() { + PtpCanonicalAuthorityEvidence e{}; + CHECK(!canonical_ptp_authority(e).clock_usable); + e.role = PtpAuthorityRole::source; + e.runtime_ready = true; + e.clock_usable_claim = true; + e.globally_traceable_claim = true; + e.generation = 0U; + CHECK(!canonical_ptp_authority(e).clock_usable); + e.generation = 17U; + auto source = canonical_ptp_authority(e); + CHECK(source.clock_usable && source.generation == 17U); + CHECK(!source.globally_traceable && !source.sv_phase_qualified); + CHECK(!source.selected_source); + e.runtime_ready = false; + CHECK(!canonical_ptp_authority(e).clock_usable); + + e.role = PtpAuthorityRole::receiver; + e.runtime_ready = true; + e.source_clock_identity = {10U, 20U, 30U, 40U, 50U, 60U, 70U, 80U}; + e.source_port = 3U; + // Stale fields left by a prior worker may not certify an active clock. + CHECK(!canonical_ptp_authority(e).clock_usable); + e.selected_source = true; + e.measured_discipline_locked = false; + CHECK(!canonical_ptp_authority(e).clock_usable); + e.measured_discipline_locked = true; + auto receiver = canonical_ptp_authority(e); + CHECK(receiver.clock_usable && receiver.globally_traceable); + CHECK(receiver.selected_source && receiver.source_port == 3U); + CHECK(receiver.source_clock_identity[7] == 80U); + CHECK(!receiver.sv_phase_qualified); + e.clock_usable_claim = false; + CHECK(!canonical_ptp_authority(e).globally_traceable); + CHECK(!canonical_ptp_authority(e).clock_usable); + e.clock_usable_claim = true; + e.globally_traceable_claim = false; + CHECK(canonical_ptp_authority(e).clock_usable); + CHECK(!canonical_ptp_authority(e).globally_traceable); + e.runtime_ready = false; + receiver = canonical_ptp_authority(e); + CHECK(!receiver.clock_usable && !receiver.selected_source); + CHECK(receiver.source_port == 0U); + e.runtime_ready = true; + e.selected_source = false; + CHECK(!canonical_ptp_authority(e).clock_usable); + e.selected_source = true; + e.generation = 0U; + CHECK(!canonical_ptp_authority(e).clock_usable); + e.generation = 18U; + e.role = PtpAuthorityRole::monitor; + CHECK(!canonical_ptp_authority(e).clock_usable); + CHECK(!canonical_ptp_authority(e).globally_traceable); + e.role = static_cast(99U); + CHECK(!canonical_ptp_authority(e).clock_usable); + std::puts("Canonical PTP timing authority: PASS"); +} diff --git a/tests/test_ptp_control_cancellation.cpp b/tests/test_ptp_control_cancellation.cpp new file mode 100644 index 000000000..9451c196b --- /dev/null +++ b/tests/test_ptp_control_cancellation.cpp @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/time_sync/ptp_control_cancellation.hpp" +#include +#include +#include +#include +using ar::iec61850::time_sync::PtpControlCancellationGate; +#define CHECK(x) do { if (!(x)) { std::fprintf(stderr,"FAIL %d: %s\n",__LINE__,#x); std::abort(); } } while(false) + +int main() { + PtpControlCancellationGate gate; + unsigned stopped = 0U; + auto stop = [&]() noexcept { ++stopped; }; + CHECK(gate.try_acquire()); + CHECK(!gate.try_acquire()); // concurrent START rejected + CHECK(!gate.request_stop_and_try_acquire()); // STOP during START + CHECK(gate.stop_requested()); // status lease immediately revoked + CHECK(!gate.finish(stop)); // START must acknowledge cancellation + CHECK(stopped == 1U); + CHECK(!gate.stop_requested()); + CHECK(gate.try_acquire()); + CHECK(gate.finish(stop)); + CHECK(stopped == 1U); + CHECK(gate.request_stop_and_try_acquire()); // STOP while idle + CHECK(!gate.try_acquire()); // blocked through cleanup + CHECK(!gate.finish(stop)); + CHECK(stopped == 2U); + + CHECK(gate.try_acquire()); + CHECK(!gate.request_stop_and_try_acquire()); + CHECK(!gate.request_stop_and_try_acquire()); // duplicate STOP + CHECK(!gate.finish([&]() noexcept { + ++stopped; + CHECK(!gate.request_stop_and_try_acquire()); + CHECK(gate.stop_requested()); // STOP concurrent with rollback + })); + CHECK(stopped == 3U); // one cleanup, all STOPs coalesced + CHECK(gate.try_acquire()); + CHECK(gate.finish(stop)); + + // Repro controlled cross-task ordering, not a timing/sleep heuristic. + for (unsigned cycle = 0; cycle < 200U; ++cycle) { + CHECK(gate.try_acquire()); + std::atomic_bool signal{false}; + std::atomic_bool delivered{false}; + std::thread requester([&]() { + while (!signal.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + CHECK(!gate.request_stop_and_try_acquire()); + delivered.store(true, std::memory_order_release); + }); + signal.store(true, std::memory_order_release); + while (!delivered.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + CHECK(gate.stop_requested()); + CHECK(!gate.finish(stop)); + requester.join(); + CHECK(!gate.stop_requested()); + } + CHECK(stopped == 203U); + std::puts("PTP control START/STOP cancellation handshake: PASS"); +} diff --git a/tests/test_ptp_emac_role_ownership.cpp b/tests/test_ptp_emac_role_ownership.cpp new file mode 100644 index 000000000..886ce7a5c --- /dev/null +++ b/tests/test_ptp_emac_role_ownership.cpp @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/time_sync/ptp_emac_role_ownership.hpp" +#include +#include +using ar::iec61850::time_sync::PtpEmacRoleOwnership; +using ar::iec61850::time_sync::can_change_ptp_emac_role; +#define CHECK(x) do { if (!(x)) { std::fprintf(stderr, "FAIL %d: %s\n",__LINE__,#x); std::abort(); } } while(false) + +int main() { + // READY=0 is NOT proof a task has released the EMAC timebase. + CHECK(can_change_ptp_emac_role({})); + CHECK(!can_change_ptp_emac_role({true,false,false})); // SOURCE starting + CHECK(!can_change_ptp_emac_role({true,false,true})); // SOURCE/STOP race + CHECK(!can_change_ptp_emac_role({false,true,false})); // RECEIVER acquiring + CHECK(!can_change_ptp_emac_role({false,true,true})); // RECEIVER cleanup + CHECK(!can_change_ptp_emac_role({false,false,true})); // STOP latch + CHECK(!can_change_ptp_emac_role({true,true,false})); // invalid coexistence + CHECK(!can_change_ptp_emac_role({true,true,true})); + // Only after both workers are finished and STOP latch cleared can + // SOURCE -> RECEIVER and RECEIVER -> SOURCE proceed. + CHECK(can_change_ptp_emac_role({false,false,false})); + std::puts("PTP EMAC role ownership fence: PASS"); +} diff --git a/tests/test_ptp_m3e_bench_gate.py b/tests/test_ptp_m3e_bench_gate.py new file mode 100644 index 000000000..89bb952b0 --- /dev/null +++ b/tests/test_ptp_m3e_bench_gate.py @@ -0,0 +1,370 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""Deterministic positive and adversarial M3E host evidence regression.""" +import importlib.util +import io +import json +from pathlib import Path +import tempfile +import sys +import unittest +import zipfile + +S = importlib.util.spec_from_file_location( + "ptp_m3e_bench_gate", Path(__file__).resolve().parents[1] / "scripts" / "ptp_m3e_bench_gate.py" +) +module = importlib.util.module_from_spec(S) +S.loader.exec_module(module) +HEAD = "7d2d5146b1892124c1744ad26ab31b3489e8387e" + + +def archive(*, change=None): + entries = {} + for role, value in (("source", 0), ("receiver", 1)): + config = ('CONFIG_IDF_TARGET="esp32p4"\n' + f'CONFIG_AR_PTP_OPERATING_ROLE={value}\n' + 'CONFIG_AR_PTP_LAB_TX=y\nCONFIG_AR_SMV_LIVE_TX=y\n' + 'CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y\n' + 'CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL=y\n' + 'CONFIG_ETH_TRANSMIT_MUTEX=y\n' + '# CONFIG_AR_SMV_BROADCAST_MIRROR is not set\n' + '# CONFIG_AR_SMV_DIAGNOSTIC_PROBES is not set\n').encode() + flash_files = { + "0x2000": "bootloader/bootloader.bin", + "0x8000": "partition_table/partition-table.bin", + "0x10000": "arstack_esp32p4_smv_injector.bin", + } + images = [] + for offset, name in flash_files.items(): + content = (HEAD[:16].encode() + role.encode() + b" APP TEST") if "/" not in name else name.encode() + entries[f"{role}/build/{name}"] = content + images.append({"flashOffset": offset, "relativeFile": "build/" + name, + "bytes": len(content), "sha256": module.sha256(content)}) + manifest = { + "kind": "ARStack61850 experimental PTP bench-only; NOT a stable release", + "boardTarget": "esp32p4", "role": role, "prHeadCommit": HEAD, + "checkoutCommit": "c" * 40, "semanticBuildId": HEAD[:16], + "hardwareValidated": False, "smpSynchAutoQualification": False, + "flashImages": images, "sdkconfigSha256": module.sha256(config), + } + entries[f"{role}/sdkconfig"] = config + entries[f"{role}/flasher_args.json"] = json.dumps({"flash_files": flash_files}).encode() + entries[f"{role}/manifest.json"] = json.dumps(manifest).encode() + if change: + change(entries) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as out: + for path, value in entries.items(): + out.writestr(path, value) + return buf.getvalue() + + +def wire_json(): + return {"evidence": "wire_only_unqualified", "svWireConsistent": True, + "ptpPacketsVisible": True, "ptpClockSynchronized": None, + "svPtpPhaseQualified": None, "iedAlarmFree": None, + "svAsdus": 8001, "smpSynch0": 8001, "smpSynch1": 0, + "smpSynch2": 0, "smpSynchUnknown": 0, + "gaps": 0, "duplicates": 0, "outOfOrder": 0, + "configurationChanges": 0, "counterZeroCaptureObservations": 2, + "normalWraps": 1} + + +class BenchGateTests(unittest.TestCase): + def setUp(self): + self.t = tempfile.TemporaryDirectory() + self.addCleanup(self.t.cleanup) + self.path = Path(self.t.name) / "firmware.zip" + self.path.write_bytes(archive()) + + def rewrite(self, change): + self.path.write_bytes(archive(change=change)) + + def test_two_role_artifact_positive(self): + src = module.verified_bundle(self.path, "source") + rx = module.verified_bundle(self.path, "receiver") + self.assertEqual(src["status"], "VERIFIED") + self.assertEqual(src["prHeadCommit"], rx["prHeadCommit"]) + self.assertNotEqual(src["appSha256"], rx["appSha256"]) + self.assertEqual(rx["offsets"], ["0x2000", "0x8000", "0x10000"]) + + def test_tampered_receiver_bin_fails_closed(self): + self.rewrite(lambda e: e.__setitem__("receiver/build/arstack_esp32p4_smv_injector.bin", b"tampered")) + with self.assertRaisesRegex(module.EvidenceError, "hash/length"): + module.verified_bundle(self.path, "source") + + def test_tampered_config_fails_closed(self): + def mutate(e): + e["source/sdkconfig"] = e["source/sdkconfig"].replace( + b"CONFIG_ETH_TRANSMIT_MUTEX=y", b"CONFIG_ETH_TRANSMIT_MUTEX=n") + self.rewrite(mutate) + with self.assertRaisesRegex(module.EvidenceError, "sdkconfig"): + module.verified_bundle(self.path, "receiver") + + def test_duplicate_zip_entries_fail(self): + with zipfile.ZipFile(self.path, "a") as z: + z.writestr("receiver/manifest.json", b"{}") + with self.assertRaisesRegex(module.EvidenceError, "duplicate ZIP"): + module.verified_bundle(self.path, "receiver") + + def test_path_traversal_fails(self): + self.rewrite(lambda e: e.__setitem__("source/../../malicious", b"x")) + with self.assertRaisesRegex(module.EvidenceError, "unsafe/unexpected"): + module.verified_bundle(self.path, "receiver") + + def test_cross_role_app_contamination_fails(self): + def mutate(e): + role = "receiver" + e[f"{role}/build/arstack_esp32p4_smv_injector.bin"] = e["source/build/arstack_esp32p4_smv_injector.bin"] + m = json.loads(e[f"{role}/manifest.json"]) + for f in m["flashImages"]: + if f["flashOffset"] == "0x10000": + f["sha256"] = module.sha256(e["source/build/arstack_esp32p4_smv_injector.bin"]) + f["bytes"] = len(e["source/build/arstack_esp32p4_smv_injector.bin"]) + e[f"{role}/manifest.json"] = json.dumps(m).encode() + self.rewrite(mutate) + with self.assertRaisesRegex(module.EvidenceError, "identical SOURCE/RECEIVER"): + module.verified_bundle(self.path, "source") + + def test_wrong_serial_build_or_role_rejected(self): + good = b'I (0) ar: ARSTACK identity product=SMV-INJECTOR build=7d2d5146b1892124 boot_id=123\nI (0): PTP2 role=RECEIVER discipline=LOCKED source=0102030405060708/2 offset=500\n' + result = module.serial_evidence(good, "receiver", HEAD[:16]) + self.assertEqual(result["reportedLockSamples"], 1) + with self.assertRaisesRegex(module.EvidenceError, "build ID mismatch"): + module.serial_evidence(good.replace(b"7d2d5146b1892124", b"0000000000000000"), "receiver", HEAD[:16]) + with self.assertRaisesRegex(module.EvidenceError, "role mismatch"): + module.serial_evidence(good, "source", HEAD[:16]) + with self.assertRaisesRegex(module.EvidenceError, "without a selected GM"): + module.serial_evidence(good.replace(b"0102030405060708/2", b"NONE"), "receiver", HEAD[:16]) + + def test_no_lock_claim_when_acquiring_or_holdover(self): + text = ("ARSTACK identity build=" + HEAD[:16] + " boot_id=abcd\n" + + "PTP2 role=RECEIVER discipline=ACQUIRING source=0102030405060708/2 offset=NA\n" + + "PTP2 role=RECEIVER discipline=HOLDOVER source=NONE offset=NA\n").encode() + result = module.serial_evidence(text, "receiver", HEAD[:16]) + self.assertEqual(result["reportedLockSamples"], 0) + + def test_wire_positive_and_false_smp_synch_rejection(self): + evidence = wire_json() + self.assertEqual(module.wire_evidence(evidence)["status"], "CONSISTENT") + evidence["smpSynch0"] -= 1 + evidence["smpSynch2"] += 1 + self.assertEqual(module.wire_evidence(evidence)["status"], "REJECTED") + evidence["svPtpPhaseQualified"] = True + with self.assertRaisesRegex(module.EvidenceError, "unmeasured hardware"): + module.wire_evidence(evidence) + + def test_short_capture_not_consistent(self): + evidence = wire_json() + evidence["counterZeroCaptureObservations"] = 1 + evidence["normalWraps"] = 0 + self.assertEqual(module.wire_evidence(evidence)["status"], "REJECTED") + + def test_reject_bad_pcap_audit_accounting(self): + evidence = wire_json() + evidence["svAsdus"] = 22 + with self.assertRaisesRegex(module.EvidenceError, "ASDU sync accounting"): + module.wire_evidence(evidence) + + + + +BOOT = "ABCDEF0123456789" +GM_A = "0102030405060708/1" +GM_B = "1122334455667788/2" + + +def ptp_line(state, gm=GM_A, *, complete=True, accepted=8): + base = f"PTP2 role=RECEIVER discipline={state} source={gm}" + if not complete: + return base + " offset=NA" + return (base + " offset=200 path=100 jitter=5 freq=0 global=0 measured=NA " + "rxAnnounce=8 rxSync=8 rxFollowUp=8 rxPdelay=8 pdelayReq=8 " + f"accepted={accepted} rejected=0") + + +def bench_log(*states, gm_seq=None, boot=BOOT): + messages = [f"ARSTACK identity build={HEAD[:16]} boot_id={boot}"] + for i, state in enumerate(states): + gm = gm_seq[i] if gm_seq else GM_A + messages.append(ptp_line(state, gm)) + return ("\n".join(messages) + "\n").encode() + + +class GateReportIntegrationTests(unittest.TestCase): + def setUp(self): + self.dir = tempfile.TemporaryDirectory() + self.addCleanup(self.dir.cleanup) + self.path = Path(self.dir.name) + self.bundle = self.path / "bundle.zip" + self.bundle.write_bytes(archive()) + self.log = self.path / "receiver.log" + self.pcap = self.path / "input.pcap" + self.pcap.write_bytes(b"a test PCAP path; mock host audit only") + self.audit = self.path / "ariec61850_ptp_sv_wire_audit" + self.audit.write_bytes(b"mock host C++ binary") + + def invoke(self, log, scenario="auto"): + import contextlib + import unittest.mock + self.log.write_bytes(log) + args = ["m3e", "--bundle", str(self.bundle), "--role", "receiver", + "--serial-log", str(self.log), "--pcap", str(self.pcap), + "--wire-audit", str(self.audit), "--sv-id", "TEST.SV", + "--app-id", "0x4000", "--counter-modulus", "4000", + "--ptp-domain", "0", "--scenario", scenario] + buffer = io.StringIO() + with unittest.mock.patch.object(sys, "argv", args), \ + unittest.mock.patch.object(module, "run_wire_audit", return_value={"status": "CONSISTENT"}), \ + contextlib.redirect_stdout(buffer): + rc = module.main() + return rc, json.loads(buffer.getvalue()) + + def test_wire_consistency_never_covers_unlocked_receiver(self): + code, report = self.invoke(bench_log("UNLOCKED", "ACQUIRING")) + self.assertEqual(code, 2) + self.assertFalse(report["softwareEvidenceConsistent"]) + self.assertEqual(report["operationalLifecycle"]["status"], "INCOMPLETE") + self.assertFalse(report["autoSmpSynchPromotionPermitted"]) + + def test_report_is_observed_not_hardware_qualified(self): + code, report = self.invoke(bench_log("UNLOCKED", "ACQUIRING", "LOCKED")) + self.assertEqual(code, 0) + self.assertTrue(report["softwareEvidenceConsistent"]) + self.assertEqual(report["operationalLifecycle"]["status"], "OBSERVED") + self.assertIsNone(report["independentHardwarePhaseQualified"]) + self.assertIsNone(report["independentPTPGrandmasterAccuracyQualified"]) + self.assertIsNone(report["relayAcceptanceQualified"]) + self.assertFalse(report["publicReleasePermitted"]) + + def test_recovery_requires_second_lock_in_cli(self): + code, report = self.invoke( + bench_log("UNLOCKED", "ACQUIRING", "LOCKED"), + scenario="receiver-recovery") + self.assertEqual(code, 2) + self.assertFalse(report["softwareEvidenceConsistent"]) + self.assertEqual(report["operationalLifecycle"]["status"], "INCOMPLETE") + + +class LifecycleScenarioTests(unittest.TestCase): + def run_gate(self, data, scenario="receiver-acquisition", role="receiver"): + return module.operational_evidence(data, role, scenario) + + def test_unlocked_only_never_qualifies_receiver(self): + evidence = self.run_gate(bench_log("UNLOCKED", "ACQUIRING")) + self.assertEqual(evidence["status"], "INCOMPLETE") + self.assertEqual(evidence["reportedLockSamples"], 0) + + def test_truncated_or_unmeasured_locked_report_not_accepted(self): + data = bench_log("UNLOCKED") + (ptp_line("LOCKED", complete=False) + "\n").encode() + self.assertEqual(self.run_gate(data)["status"], "INCOMPLETE") + data = bench_log("UNLOCKED") + (ptp_line("LOCKED", accepted=0) + "\n").encode() + self.assertEqual(self.run_gate(data)["status"], "INCOMPLETE") + + def test_cold_acquisition_observed_from_ordered_evidence(self): + actual = self.run_gate(bench_log("UNLOCKED", "ACQUIRING", "LOCKED")) + self.assertEqual(actual["status"], "OBSERVED") + self.assertEqual(actual["reportedLockSamples"], 1) + self.assertFalse(actual["reportedRecovery"]) + + def test_lock_without_prior_acquisition_not_an_acquisition(self): + self.assertEqual(self.run_gate(bench_log("LOCKED"))["status"], "INCOMPLETE") + + def test_recovery_requires_demotion_and_a_second_valid_lock(self): + self.assertEqual(self.run_gate( + bench_log("LOCKED", "LOCKED"), "receiver-recovery")["status"], "INCOMPLETE") + actual = self.run_gate( + bench_log("LOCKED", "HOLDOVER", "UNLOCKED", "ACQUIRING", "LOCKED"), + "receiver-recovery") + self.assertEqual(actual["status"], "OBSERVED") + self.assertTrue(actual["reportedRecovery"]) + + def test_gm_switch_requires_an_intervening_demotion(self): + actual = self.run_gate(bench_log("LOCKED", "UNLOCKED", "LOCKED", + gm_seq=[GM_A, GM_A, GM_B]), + "receiver-gm-handoff") + self.assertEqual(actual["status"], "OBSERVED") + self.assertTrue(actual["reportedGmHandoff"]) + directly = self.run_gate(bench_log("LOCKED", "LOCKED", + gm_seq=[GM_A, GM_B]), + "receiver-gm-handoff") + self.assertEqual(directly["status"], "INCOMPLETE") + self.assertTrue(directly["directGmHandoffWithoutDemotion"]) + same_gm = self.run_gate(bench_log("LOCKED", "HOLDOVER", "LOCKED"), + "receiver-gm-handoff") + self.assertEqual(same_gm["status"], "INCOMPLETE") + + def test_any_unfenced_direct_gm_swap_vetoes_later_good_transition(self): + data = bench_log("LOCKED", "LOCKED", "UNLOCKED", "LOCKED", + gm_seq=[GM_A, GM_B, GM_B, GM_A]) + result = self.run_gate(data, "receiver-gm-handoff") + self.assertTrue(result["reportedGmHandoff"]) + self.assertEqual(result["status"], "INCOMPLETE") + + def test_multiple_boot_sessions_cannot_be_combined(self): + data = bench_log("UNLOCKED", "ACQUIRING") + bench_log("LOCKED", boot="1234567890ABCDEF") + self.assertEqual(self.run_gate(data)["status"], "INCOMPLETE") + + def test_unknown_boot_id_does_not_certify_a_scenario(self): + data = bench_log("ACQUIRING", "LOCKED", boot="NA") + self.assertEqual(self.run_gate(data)["status"], "INCOMPLETE") + + def test_source_requires_actual_sent_ptp_triplet(self): + base = (f"ARSTACK identity build={HEAD[:16]} boot_id={BOOT}\n" + "PTP2 role=SOURCE discipline=UNLOCKED source=NONE offset=NA\n") + incomplete = self.run_gate(base.encode(), "source-ready", "source") + self.assertEqual(incomplete["status"], "INCOMPLETE") + complete = self.run_gate((base + + "PTP status=RUNNING Announce=4 Sync=4 FollowUp=4 PdelayFrames=0 TXfail=0\n").encode(), + "source-ready", "source") + self.assertEqual(complete["status"], "OBSERVED") + + def test_source_readiness_is_latest_not_any_historical_running(self): + prefix = (f"ARSTACK identity build={HEAD[:16]} boot_id={BOOT}\n" + "PTP2 role=SOURCE discipline=UNLOCKED source=NONE offset=NA\n") + running = "PTP status=RUNNING Announce=4 Sync=4 FollowUp=4 PdelayFrames=0 TXfail=0\n" + for suffix in ("PTP stop requested\n", + "PTP status=STOPPED Announce=4 Sync=4 FollowUp=4 PdelayFrames=0 TXfail=0\n", + "PTP status unavailable\n", + "PTP start rejected: link down\n", + "PTP status=RUNNING Announce=4 Sync=4 FollowUp=4 PdelayFrames=0\n"): + with self.subTest(suffix=suffix): + actual = self.run_gate((prefix + running + suffix).encode(), + "source-ready", "source") + self.assertEqual(actual["status"], "INCOMPLETE") + # A later clean RUNNING status is evidence of a newly reported state. + clean_restart = prefix + running + "PTP stop requested\n" + running + self.assertEqual(self.run_gate(clean_restart.encode(), "source-ready", + "source")["status"], "OBSERVED") + + def test_source_any_reported_tx_failure_vetoes_same_boot(self): + prefix = (f"ARSTACK identity build={HEAD[:16]} boot_id={BOOT}\n" + "PTP2 role=SOURCE discipline=UNLOCKED source=NONE offset=NA\n") + bad = "PTP status=RUNNING Announce=4 Sync=4 FollowUp=4 PdelayFrames=0 TXfail=1\n" + good = "PTP status=RUNNING Announce=8 Sync=8 FollowUp=8 PdelayFrames=0 TXfail=0\n" + actual = self.run_gate((prefix + bad + good).encode(), "source-ready", "source") + self.assertEqual(actual["status"], "INCOMPLETE") + self.assertTrue(actual["sourceTxFaultObserved"]) + self.assertEqual(self.run_gate((prefix + bad).encode(), "source-ready", + "source")["status"], "INCOMPLETE") + + def test_missing_boot_id_on_later_identity_fails_closed(self): + data = (f"ARSTACK identity build={HEAD[:16]} boot_id={BOOT}\n" + "PTP2 role=SOURCE discipline=UNLOCKED source=NONE offset=NA\n" + f"ARSTACK identity build={HEAD[:16]}\n" + "PTP status=RUNNING Announce=4 Sync=4 FollowUp=4 PdelayFrames=0 TXfail=0\n") + self.assertEqual(self.run_gate(data.encode(), "source-ready", + "source")["status"], "INCOMPLETE") + + def test_incompatible_role_scenario_is_rejected(self): + with self.assertRaisesRegex(module.EvidenceError, "incompatible"): + self.run_gate(bench_log("LOCKED"), "source-ready", "receiver") + + def test_positive_wire_is_not_sufficient_for_unlocked_receiver(self): + full = module.wire_evidence(wire_json()) + self.assertEqual(full["status"], "CONSISTENT") + lifecycle = self.run_gate(bench_log("UNLOCKED", "ACQUIRING")) + self.assertNotEqual(lifecycle["status"], "OBSERVED") + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_ptp_power_profile.cpp b/tests/test_ptp_power_profile.cpp new file mode 100644 index 000000000..a951251f0 --- /dev/null +++ b/tests/test_ptp_power_profile.cpp @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/time_sync/ptp_power_profile.hpp" +#include "ariec61850/time_sync/ptp.hpp" +#include "ariec61850/time_sync/ptp_tai_source.hpp" + +#include +#include +#include +#include +#include +#include +#include + +using namespace ar::iec61850::time_sync; + +#define REQUIRE(value) do { if (!(value)) { \ + throw std::runtime_error(std::string{"Check failed: "} + #value + \ + " line " + std::to_string(__LINE__)); \ +} } while (false) + +namespace { + +PtpPublisherOptions make_source() { + PtpPublisherOptions options; + options.source_mac = {0x02U, 0x31U, 0x51U, 0x71U, 0x91U, 0xB1U}; + options.clock_identity = ptp_clock_identity_from_mac(options.source_mac); + options.clock_class = 248U; + options.clock_accuracy = PtpClockAccuracy::unknown; + options.time_source = PtpTimeSource::internal_oscillator; + options.respond_to_peer_delay = true; + return options; +} + +void check_defaults_and_reject_nonstandard_cadence() { + auto profile = make_source(); + REQUIRE(inspect_ptp_power_profile_cadence(profile).compatible()); + profile.sync_interval = std::chrono::milliseconds{250}; + REQUIRE(!inspect_ptp_power_profile_cadence(profile).compatible()); + profile.sync_interval = std::chrono::milliseconds{1000}; + profile.announce_interval = std::chrono::milliseconds{2000}; + REQUIRE(!inspect_ptp_power_profile_cadence(profile).compatible()); + profile.announce_interval = std::chrono::milliseconds{1000}; + profile.respond_to_peer_delay = false; + REQUIRE(!inspect_ptp_power_profile_cadence(profile).compatible()); +} + +void check_source_readiness_requires_fresh_wire_proof() { + PtpSourceReadinessGate gate; + REQUIRE(!gate.ready()); + gate.observe_peer_delay(true); + REQUIRE(!gate.ready()); // Pdelay response is never Sync evidence. + gate.observe_announce(true); + REQUIRE(!gate.ready()); + gate.observe_sync_pair(true); + REQUIRE(gate.ready()); + + gate.observe_sync_pair(false); // e.g., Follow_Up failed after Sync TX + REQUIRE(!gate.ready()); + gate.observe_peer_delay(true); + REQUIRE(!gate.ready()); // previously sent Announce/Sync must not count + gate.observe_sync_pair(true); + REQUIRE(!gate.ready()); // need a *fresh* Announce too + gate.observe_announce(true); + REQUIRE(gate.ready()); + + gate.observe_peer_delay(false); + REQUIRE(!gate.ready()); + gate.observe_announce(true); + REQUIRE(!gate.ready()); + gate.observe_sync_pair(true); + REQUIRE(gate.ready()); + + // A failed Announce revokes readiness even if the Sync pair was fine. + gate.observe_announce(false); + REQUIRE(!gate.ready()); + gate.observe_announce(true); + REQUIRE(!gate.ready()); + gate.observe_sync_pair(true); + REQUIRE(gate.ready()); + gate.invalidate(); // SOURCE STOP / teardown + REQUIRE(!gate.ready()); +} + +void check_source_heartbeat_freshness_and_time_provenance() { + // A late task or absent Announce must revoke READY without waiting for + // another SOURCE loop. A clock going backward is never healthy. + using Fresh = PtpSourceEvidenceFreshness; + REQUIRE(!Fresh::fresh(5000000, 0, 4700000, 3000000)); + REQUIRE(!Fresh::fresh(5000000, 4700000, 0, 3000000)); + REQUIRE(Fresh::fresh(5000000, 3000001, 4200000, 3000000)); + REQUIRE(!Fresh::fresh(5000000, 2000000, 4200000, 3000000)); + REQUIRE(!Fresh::fresh(5000000, 4200000, 1999999, 3000000)); + REQUIRE(!Fresh::fresh(5000000, 5100000, 4200000, 3000000)); + REQUIRE(!Fresh::fresh(5000000, 4200000, 5100000, 3000000)); + REQUIRE(!Fresh::fresh(5000000, 4200000, 4200000, 0)); + REQUIRE(Fresh::fresh(8000000, 6000000, 7000000, 3000000)); + REQUIRE(!Fresh::fresh(10000001, 6000000, 7000000, 3000000)); + + const auto unverified = conservative_lab_time_properties(true, false); + REQUIRE(unverified.flags() == 0U); + REQUIRE(!unverified.ptp_timescale); + const auto unseeded = conservative_lab_time_properties(false, true); + REQUIRE(unseeded.flags() == 0U); + const auto verified = conservative_lab_time_properties(true, true); + REQUIRE(verified.flags() == 0x000CU); + REQUIRE(!verified.time_traceable && !verified.frequency_traceable); + // Test actual wire bytes separately from the code's own PTP decoder. + auto options = make_source(); + options.time_properties = unverified; + PtpPublisherRuntime source{options}; + REQUIRE(source.start()); + const auto first = source.prepare_announce(PtpTimestamp{1700000037U, 0U}); + REQUIRE(first.has_value()); + REQUIRE(first->ethernet_frame[20] == 0x00U); + REQUIRE(first->ethernet_frame[21] == 0x00U); + source.stop(); + + options.time_properties = verified; + PtpPublisherRuntime explicit_offset{options}; + REQUIRE(explicit_offset.start()); + const auto second = explicit_offset.prepare_announce(PtpTimestamp{1700000037U, 0U}); + REQUIRE(second.has_value()); + REQUIRE(second->ethernet_frame[20] == 0x00U); + REQUIRE(second->ethernet_frame[21] == 0x0CU); + explicit_offset.stop(); +} + +void check_ptp_wire_sequence_and_identity() { + auto options = make_source(); + constexpr std::uint8_t domain = 93U; // supported alternate network domain + options.domain_number = domain; + PtpPublisherRuntime source{options}; + const auto start_time = std::chrono::steady_clock::time_point{}; + REQUIRE(source.start(std::chrono::system_clock::time_point{}, start_time)); + auto due = source.poll_due(start_time); + REQUIRE(due.announce && due.sync); + due = source.poll_due(start_time + std::chrono::milliseconds{999}); + REQUIRE(!due.announce && !due.sync); + due = source.poll_due(start_time + std::chrono::milliseconds{1000}); + REQUIRE(due.announce && due.sync); + + const auto announce = source.prepare_announce(PtpTimestamp{1000U, 0U}); + REQUIRE(announce.has_value()); + PtpFrame frame; + REQUIRE(PtpCodec::try_parse_ethernet_frame(announce->ethernet_frame, frame)); + REQUIRE(frame.header.message_type == PtpMessageType::announce); + REQUIRE(frame.header.version == ptp_version); + REQUIRE(frame.header.domain_number == domain); + REQUIRE(frame.header.log_message_interval == 0); + REQUIRE(frame.announce.has_value()); + REQUIRE(frame.announce->clock_class == 248U); + REQUIRE(frame.announce->clock_accuracy == PtpClockAccuracy::unknown); + REQUIRE(frame.announce->time_source == PtpTimeSource::internal_oscillator); + REQUIRE(frame.announce->grandmaster_identity == options.clock_identity); + REQUIRE(frame.announce->steps_removed == 0U); + REQUIRE((frame.header.flags & 0x0030U) == 0U); // no false traceability + REQUIRE(std::equal(ptp_general_multicast_mac.begin(), + ptp_general_multicast_mac.end(), + announce->ethernet_frame.begin())); + REQUIRE(announce->ethernet_frame[12U] == 0x88U); + REQUIRE(announce->ethernet_frame[13U] == 0xF7U); + + const auto sync = source.prepare_sync(PtpTimestamp{}); + REQUIRE(sync.has_value()); + REQUIRE(PtpCodec::try_parse_ethernet_frame(sync->ethernet_frame, frame)); + REQUIRE(frame.header.message_type == PtpMessageType::sync); + REQUIRE(frame.header.log_message_interval == 0); + REQUIRE(frame.header.is_two_step()); + REQUIRE(frame.header.domain_number == domain); + + const PtpTimestamp captured_egress_time{1001U, 345678901U}; + const auto follow = source.prepare_follow_up(sync->sequence_id, captured_egress_time); + REQUIRE(follow.has_value()); + REQUIRE(PtpCodec::try_parse_ethernet_frame(follow->ethernet_frame, frame)); + REQUIRE(frame.header.message_type == PtpMessageType::follow_up); + REQUIRE(frame.header.sequence_id == sync->sequence_id); + REQUIRE(frame.header.log_message_interval == 0); + REQUIRE(frame.timestamp == captured_egress_time); + + const PtpPortIdentity requesting_port{ + {0x02U, 0x00U, 0x00U, 0xFFU, 0xFEU, 0x00U, 0x10U, 0x20U}, 1U}; + const auto reply = source.prepare_pdelay_response( + requesting_port, 33U, 0, PtpTimestamp{1001U, 12000U}); + REQUIRE(reply.has_value()); + REQUIRE(PtpCodec::try_parse_ethernet_frame(reply->ethernet_frame, frame)); + REQUIRE(frame.header.message_type == PtpMessageType::pdelay_resp); + REQUIRE(frame.header.sequence_id == 33U); + REQUIRE(frame.header.log_message_interval == 0); + REQUIRE(frame.peer_delay_multicast); + REQUIRE(std::equal(ptp_peer_delay_multicast_mac.begin(), + ptp_peer_delay_multicast_mac.end(), + reply->ethernet_frame.begin())); + + const auto reply_follow = source.prepare_pdelay_response_follow_up( + requesting_port, 33U, 0, PtpTimestamp{1001U, 13000U}); + REQUIRE(reply_follow.has_value()); + REQUIRE(PtpCodec::try_parse_ethernet_frame(reply_follow->ethernet_frame, frame)); + REQUIRE(frame.header.message_type == PtpMessageType::pdelay_resp_follow_up); + REQUIRE(frame.header.sequence_id == 33U); + + source.stop(); +} + +void check_tai_seed_and_truthful_time_properties() { + // The physical clock uses unsigned 32-bit seconds; reject unavailable + // system time, stale RTC and arithmetic overflow rather than wrapping. + REQUIRE(!try_seed_ptp_tai_seconds(0, 37).has_value()); + REQUIRE(!try_seed_ptp_tai_seconds(1577836799LL, 37).has_value()); + REQUIRE(!try_seed_ptp_tai_seconds(1700000000LL, -1).has_value()); + REQUIRE(!try_seed_ptp_tai_seconds(1700000000LL, 201).has_value()); + REQUIRE(!try_seed_ptp_tai_seconds(4294967290LL, 37).has_value()); + REQUIRE(try_seed_ptp_tai_seconds(1700000000LL, 37) == + std::optional{1700000037U}); + REQUIRE(try_seed_ptp_tai_seconds(4294967258LL, 37) == + std::optional{4294967295U}); + + auto options = make_source(); + options.time_properties.ptp_timescale = true; + // Local RTC-derived TAI is not UTC/GNSS traceable, even with a TAI epoch. + PtpPublisherRuntime local_clock{options}; + REQUIRE(local_clock.start()); + const auto announce = local_clock.prepare_announce(PtpTimestamp{1700000037U, 0U}); + REQUIRE(announce.has_value()); + PtpFrame frame; + REQUIRE(PtpCodec::try_parse_ethernet_frame(announce->ethernet_frame, frame)); + REQUIRE(frame.header.flags == 0x0008U); + // Independent raw-octet check, so encoder/parser cannot agree on + // an identical but incorrectly located flagField. + REQUIRE(announce->ethernet_frame[20U] == 0x00U); + REQUIRE(announce->ethernet_frame[21U] == 0x08U); + REQUIRE(frame.announce.has_value()); + REQUIRE(frame.announce->current_utc_offset == 37); + REQUIRE(frame.announce->clock_class == 248U); + + const auto sync = local_clock.prepare_sync(PtpTimestamp{}); + REQUIRE(sync.has_value()); + REQUIRE(PtpCodec::try_parse_ethernet_frame(sync->ethernet_frame, frame)); + REQUIRE(frame.header.flags == 0x0208U); // two-step + PTP timescale + REQUIRE(sync->ethernet_frame[20U] == 0x02U); + REQUIRE(sync->ethernet_frame[21U] == 0x08U); + const auto follow_up = local_clock.prepare_follow_up( + sync->sequence_id, PtpTimestamp{1700000038U, 123456U}); + REQUIRE(follow_up.has_value()); + REQUIRE(PtpCodec::try_parse_ethernet_frame(follow_up->ethernet_frame, frame)); + REQUIRE(frame.header.flags == 0x0008U); + local_clock.stop(); + + // An explicitly verified TAI-UTC offset is a separate assertion; + // it does not implicitly enable timeTraceable/frequencyTraceable. + options.time_properties.current_utc_offset_valid = true; + PtpPublisherRuntime manually_verified{options}; + REQUIRE(manually_verified.start()); + const auto verified_announce = manually_verified.prepare_announce( + PtpTimestamp{1700000037U, 0U}); + REQUIRE(verified_announce.has_value()); + REQUIRE(PtpCodec::try_parse_ethernet_frame( + verified_announce->ethernet_frame, frame)); + REQUIRE(frame.header.flags == 0x000CU); + REQUIRE(verified_announce->ethernet_frame[20U] == 0x00U); + REQUIRE(verified_announce->ethernet_frame[21U] == 0x0CU); + REQUIRE((frame.header.flags & 0x0030U) == 0U); + manually_verified.stop(); + + // Time-properties must never be inferred from PTP packet visibility. + PtpPublisherRuntime unseeded{make_source()}; + REQUIRE(unseeded.start()); + const auto unseeded_announce = unseeded.prepare_announce( + PtpTimestamp{1U, 0U}); + REQUIRE(unseeded_announce.has_value()); + REQUIRE(PtpCodec::try_parse_ethernet_frame( + unseeded_announce->ethernet_frame, frame)); + REQUIRE(frame.header.flags == 0U); + unseeded.stop(); + + auto inconsistent = make_source(); + inconsistent.time_properties.current_utc_offset_valid = true; + std::string error; + REQUIRE(!PtpPublisherRuntime::validate_options(inconsistent, error)); + REQUIRE(!error.empty()); +} + +void check_vlan_encoding_remains_configurable() { + auto options = make_source(); + options.vlan_id = std::uint16_t{100U}; + options.vlan_priority = std::uint8_t{4U}; + PtpPublisherRuntime source{options}; + REQUIRE(source.start()); + const auto announce = source.prepare_announce(PtpTimestamp{1000U, 0U}); + REQUIRE(announce.has_value()); + PtpFrame parsed; + REQUIRE(PtpCodec::try_parse_ethernet_frame(announce->ethernet_frame, parsed)); + REQUIRE(parsed.vlan_id == 100U); + REQUIRE(announce->ethernet_frame[12U] == 0x81U); + REQUIRE(announce->ethernet_frame[13U] == 0x00U); + source.stop(); +} + +} // namespace + +int main() { + try { + check_defaults_and_reject_nonstandard_cadence(); + check_source_readiness_requires_fresh_wire_proof(); + check_source_heartbeat_freshness_and_time_provenance(); + check_ptp_wire_sequence_and_identity(); + check_vlan_encoding_remains_configurable(); + check_tai_seed_and_truthful_time_properties(); + std::cout << "PTP Power Profile wire and configuration checks passed\n"; + return 0; + } catch (const std::exception& e) { + std::cerr << e.what() << '\n'; + return 1; + } +} diff --git a/tests/test_ptp_receiver_clock_authority.cpp b/tests/test_ptp_receiver_clock_authority.cpp new file mode 100644 index 000000000..9ba67f501 --- /dev/null +++ b/tests/test_ptp_receiver_clock_authority.cpp @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/time_sync/ptp_receiver_clock_authority.hpp" +#include +#include +#include +using ar::iec61850::time_sync::PtpReceiverClockAuthority; +using ar::iec61850::time_sync::PtpReceiverClockObservation; +#define CHECK(expr) do { if (!(expr)) { std::fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #expr); std::abort(); } } while (false) +int main() { + PtpReceiverClockAuthority authority; + PtpReceiverClockObservation sample{}; + CHECK(!authority.observe(sample).clock_usable); + CHECK(authority.observe(sample).generation == 0U); + sample.runtime_ready = true; + auto pending = authority.observe(sample); + CHECK(!pending.clock_usable && pending.generation != 0U); + sample.source_selected = true; + sample.source_clock_identity = {1U, 2U, 3U, 4U, 5U, 6U, 7U, 8U}; + sample.source_port_number = 1U; + auto acquiring = authority.observe(sample); + CHECK(!acquiring.clock_usable); + CHECK(acquiring.generation != pending.generation); + sample.discipline_locked = true; + auto locked = authority.observe(sample); + CHECK(locked.clock_usable); + CHECK(locked.generation != acquiring.generation); + CHECK(authority.observe(sample).generation == locked.generation); + + sample.clock_discontinuity = true; + auto stepped = authority.observe(sample); + CHECK(!stepped.clock_usable && stepped.generation != locked.generation); + sample.clock_discontinuity = false; + CHECK(!authority.observe(sample).clock_usable); // stale LOCKED + sample.discipline_locked = false; + auto reacquiring = authority.observe(sample); + CHECK(!reacquiring.clock_usable); + sample.discipline_locked = true; + auto recovered = authority.observe(sample); + CHECK(recovered.clock_usable && recovered.generation != reacquiring.generation); + + sample.source_clock_identity[7] = 9U; + auto changed_gm = authority.observe(sample); + CHECK(!changed_gm.clock_usable); + CHECK(changed_gm.generation != recovered.generation); + sample.discipline_locked = false; + CHECK(!authority.observe(sample).clock_usable); + sample.discipline_locked = true; + CHECK(authority.observe(sample).clock_usable); + + sample.source_port_number = 2U; + CHECK(!authority.observe(sample).clock_usable); + sample.discipline_locked = false; + CHECK(!authority.observe(sample).clock_usable); + sample.discipline_locked = true; + CHECK(authority.observe(sample).clock_usable); + + sample.clock_discontinuity = true; // failed frequency actuation + CHECK(!authority.observe(sample).clock_usable); + sample.clock_discontinuity = false; + CHECK(!authority.observe(sample).clock_usable); + sample.discipline_locked = false; + static_cast(authority.observe(sample)); + sample.discipline_locked = true; + CHECK(authority.observe(sample).clock_usable); + + sample.discipline_locked = false; // HOLDOVER / unlock + CHECK(!authority.observe(sample).clock_usable); + sample.source_selected = false; // source timeout + CHECK(!authority.observe(sample).clock_usable); + sample.runtime_ready = false; // STOP + const auto stopped = authority.observe(sample); + CHECK(!stopped.clock_usable); + sample.runtime_ready = true; // rapid restart + sample.source_selected = true; + const auto restarting = authority.observe(sample); + CHECK(!restarting.clock_usable); + CHECK(restarting.generation != stopped.generation); + sample.discipline_locked = true; + CHECK(authority.observe(sample).clock_usable); + sample.runtime_ready = false; // MONITOR adapter never admits + CHECK(!authority.observe(sample).clock_usable); + std::puts("PTP receiver clock authority fencing: PASS"); + return 0; +} diff --git a/tests/test_ptp_runtime.cpp b/tests/test_ptp_runtime.cpp index 78bc2cf9b..5626bb3a5 100644 --- a/tests/test_ptp_runtime.cpp +++ b/tests/test_ptp_runtime.cpp @@ -196,6 +196,49 @@ void peer_delay_runtime_preserves_request_identity_and_hw_timestamps() { CHECK(runtime.status().peer_delay_responses_sent == std::uint64_t{2U}); } +void fixed_deadline_cadence_survives_scheduler_latency() { + using namespace ar::iec61850::time_sync; + auto opts = make_options(); + opts.announce_interval = std::chrono::milliseconds{1000}; + opts.sync_interval = std::chrono::milliseconds{1000}; + PtpPublisherRuntime source{opts}; + const auto start = std::chrono::steady_clock::time_point{}; + CHECK(source.start(std::chrono::system_clock::time_point{}, start)); + auto due = source.poll_due(start); + CHECK(due.announce && due.sync); + // A late poll must not permanently shift the next 1s deadline by 7ms. + due = source.poll_due(start + std::chrono::milliseconds{1007}); + CHECK(due.announce && due.sync); + due = source.poll_due(start + std::chrono::milliseconds{1999}); + CHECK(!due.announce && !due.sync); + due = source.poll_due(start + std::chrono::milliseconds{2001}); + CHECK(due.announce && due.sync); + + // After a long pause, send at most ONE pair: never generate bursts. + due = source.poll_due(start + std::chrono::milliseconds{10250}); + CHECK(due.announce && due.sync); + due = source.poll_due(start + std::chrono::milliseconds{10251}); + CHECK(!due.announce && !due.sync); + due = source.poll_due(start + std::chrono::milliseconds{10999}); + CHECK(!due.announce && !due.sync); + due = source.poll_due(start + std::chrono::milliseconds{11001}); + CHECK(due.announce && due.sync); + source.stop(); + + // Even with different intervals the periods must remain independent. + opts.sync_interval = std::chrono::milliseconds{250}; + PtpPublisherRuntime subsecond{opts}; + CHECK(subsecond.start(std::chrono::system_clock::time_point{}, start)); + due = subsecond.poll_due(start); + CHECK(due.announce && due.sync); + due = subsecond.poll_due(start + std::chrono::milliseconds{261}); + CHECK(!due.announce && due.sync); + due = subsecond.poll_due(start + std::chrono::milliseconds{499}); + CHECK(!due.announce && !due.sync); + due = subsecond.poll_due(start + std::chrono::milliseconds{501}); + CHECK(!due.announce && due.sync); +} + void one_step_policy_suppresses_follow_up() { using namespace ar::iec61850::time_sync; auto options = make_options(); @@ -220,6 +263,7 @@ int main() { {"PTP ClockIdentity helpers", clock_identity_helpers_match_ariec61850_behavior}, {"PTP runtime option validation", options_are_fail_safe_and_reconfiguration_requires_stop}, {"PTP runtime scheduler/status", scheduler_sequence_and_status_match_runtime_contract}, + {"PTP deterministic cadence under delayed polling", fixed_deadline_cadence_survives_scheduler_latency}, {"PTP runtime hardware Pdelay preparation", peer_delay_runtime_preserves_request_identity_and_hw_timestamps}, {"PTP runtime one-step policy", one_step_policy_suppresses_follow_up}, }; diff --git a/tests/test_sv_ptp_clock_lease.cpp b/tests/test_sv_ptp_clock_lease.cpp new file mode 100644 index 000000000..c26497750 --- /dev/null +++ b/tests/test_sv_ptp_clock_lease.cpp @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/time_sync/sv_ptp_clock_lease.hpp" +#include "ariec61850/time_sync/sv_ptp_phase_authority.hpp" +#include +#include +using namespace ar::iec61850::time_sync; +#define CHECK(x) do { if (!(x)) { std::fprintf(stderr, "FAIL %d: %s\n",__LINE__,#x); std::abort(); } } while(false) + +int main() { + SvPtpClockLease src{}; + src.role = PtpAuthorityRole::source; + src.generation = 7U; + src.clock_usable = true; + CHECK(valid_sv_ptp_clock_lease(src)); + CHECK(same_sv_ptp_clock_lease(src,src)); + auto swapped = src; + swapped.role = PtpAuthorityRole::receiver; + swapped.source_selected = true; + swapped.source_port = 1U; + swapped.source_clock_identity = {1U,2U,3U,4U,5U,6U,7U,8U}; + CHECK(valid_sv_ptp_clock_lease(swapped)); + CHECK(!same_sv_ptp_clock_lease(src,swapped)); // same generation, wrong role + CHECK(!same_sv_ptp_clock_lease(swapped,src)); + auto gm = swapped; + gm.source_clock_identity[7] = 9U; + CHECK(!same_sv_ptp_clock_lease(swapped,gm)); // GM handoff + gm = swapped; + gm.source_port = 2U; + CHECK(!same_sv_ptp_clock_lease(swapped,gm)); // same GM, new port + gm = swapped; + ++gm.generation; + CHECK(!same_sv_ptp_clock_lease(swapped,gm)); // clock step / relock + gm = swapped; + gm.clock_usable = false; + CHECK(!same_sv_ptp_clock_lease(swapped,gm)); // holdover/timeout + gm = swapped; + gm.source_selected = false; + CHECK(!valid_sv_ptp_clock_lease(gm)); + gm = swapped; + gm.source_port = 0U; + CHECK(!valid_sv_ptp_clock_lease(gm)); + gm = swapped; + gm.source_clock_identity = {}; + CHECK(!valid_sv_ptp_clock_lease(gm)); + gm = swapped; + gm.generation = 0U; + CHECK(!valid_sv_ptp_clock_lease(gm)); + src.clock_usable = false; + CHECK(!valid_sv_ptp_clock_lease(src)); + src.clock_usable = true; + CHECK(same_sv_ptp_clock_lease(src,src)); + + // Even a previously qualified phase controller must cold-acquire after + // a role/GM/relock fence violation; no stale PI steering crosses it. + SvPtpPhaseAuthority phase; + SvPtpPhaseObservation bad{100000U,100008U,0U,1U,0U,100000000U}; + CHECK(!phase.observe(bad).accepted); // invalid PTP timestamp + SvPtpPhaseObservation a{1100000U,1100008U,1000000U,1U,101U,100000000U}; + CHECK(phase.observe(a).accepted); + a = {2100000U,2100008U,2000000U,2U,102U,100000000U}; + CHECK(phase.observe(a).accepted); + CHECK(!same_sv_ptp_clock_lease(swapped,gm)); + CHECK(phase.invalidate().slew_ticks_next_epoch == 0); + a.last_sv_epoch_generation = 3U; + a.timer_before_ticks += 1000000U; + a.timer_after_ticks += 1000000U; + a.last_sv_epoch_ticks += 1000000U; + ++a.ptp_seconds; + const auto cold = phase.observe(a); + CHECK(cold.accepted && !cold.epoch_second_continuous && + cold.slew_ticks_next_epoch == 0); + std::puts("SV receiver PTP lease fence: PASS"); + return 0; +} diff --git a/tests/test_sv_ptp_epoch.cpp b/tests/test_sv_ptp_epoch.cpp new file mode 100644 index 000000000..57117e4ca --- /dev/null +++ b/tests/test_sv_ptp_epoch.cpp @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/time_sync/sv_ptp_epoch.hpp" +#include "ariec61850/sampled_values/rational_schedule.hpp" + +#include +#include +#include +#include + +using namespace ar::iec61850::time_sync; + +int main() { + const auto require = [](bool valid, const char* why) { + if (!valid) { std::cerr << why << '\n'; return false; } + return true; + }; + const auto at_quarter = plan_sv_ptp_second_arm({250000000U, 100U, 108U}); + if (!require(at_quarter.has_value() && at_quarter->first_alarm_us == 749996U, + "PTP quarter-second epoch failed")) return 1; + const auto near_second = plan_sv_ptp_second_arm({999995000U, 0U, 2U}); + if (!require(near_second.has_value() && near_second->first_alarm_us == 1000004U, + "near-boundary must roll forward safely")) return 1; + // Earlier PTP reads must not translate into late timer alarms. + const auto rebased = rebase_sv_ptp_second_arm(*at_quarter, 108U, 308U); + if (!require(rebased.has_value() && + rebased->first_alarm_us == 749796U && + rebased->clock_read_uncertainty_us == + at_quarter->clock_read_uncertainty_us, + "GPTimer START setup latency was not compensated")) return 1; + const auto late = rebase_sv_ptp_second_arm( + {200000U, 5U}, 1000U, 191000U, 20000U, 200000U); + if (!require(late.has_value() && late->first_alarm_us == 1010000U, + "late first alarm must defer one full PTP second")) return 1; + if (!require(!rebase_sv_ptp_second_arm(*at_quarter, 108U, 100109U), + "stale PTP clock read accepted")) return 1; + if (!require(!rebase_sv_ptp_second_arm(*at_quarter, 108U, 107U), + "backwards monotonic arm time accepted")) return 1; + if (!require(!rebase_sv_ptp_second_arm({0U, 0U}, 108U, 110U), + "invalid zero alarm accepted")) return 1; + const auto start = plan_sv_ptp_second_arm({0U, 100U, 100U}); + if (!require(start.has_value() && start->first_alarm_us == 1000000U, + "exact second must target next second")) return 1; + if (!require(!plan_sv_ptp_second_arm({1000000000U, 0U, 1U}).has_value(), + "invalid nanoseconds accepted")) return 1; + if (!require(!plan_sv_ptp_second_arm({100U, 100U, 90U}).has_value(), + "backwards clock bracket accepted")) return 1; + if (!require(!plan_sv_ptp_second_arm({100U, 0U, 5000U}).has_value(), + "unbounded EMAC read accepted")) return 1; + if (!require(!qualified_sv_sync_value(SmpSynchValue::global_synchronized, false), + "PTP lock falsely promoted SV sync")) return 1; + if (!require(qualified_sv_sync_value(SmpSynchValue::local_synchronized, true) == + SmpSynchValue::local_synchronized, + "qualified local clock value lost")) return 1; + if (!require(!qualified_sv_sync_value(std::nullopt, true), + "missing clock evidence accepted")) return 1; + // Pure scheduling proof: the existing portable rational scheduler has + // precisely one million timer ticks per whole 4000/4800-sample epoch. + // This validates arithmetic/rollover, NOT physical EMAC egress phase. + for (const auto rate : std::array{4000U, 4800U}) { + ar::iec61850::sampled_values::RationalTickSchedule schedule{1000000ULL, rate}; + std::uint64_t elapsed_ticks = 0U; + std::uint32_t counter = 0U; + for (std::uint32_t sample = 0U; sample < rate * 120U; ++sample) { + const auto duration = schedule.next_interval_ticks(); + if (!require(duration == 1000000U / rate || + duration == (1000000U / rate) + 1U, + "rational cadence interval invalid")) return 1; + elapsed_ticks += duration; + counter = (counter + 1U) % rate; + if ((sample + 1U) % rate == 0U) { + if (!require(elapsed_ticks == (sample + 1ULL) / rate * 1000000ULL, + "rational schedule drifted from 1-second epoch")) return 1; + if (!require(counter == 0U, "sample counter rollover not at epoch")) return 1; + } + } + } + std::cout << "SV/PTP epoch and synchronization gates passed\n"; + return 0; +} diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp new file mode 100644 index 000000000..99805e75d --- /dev/null +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -0,0 +1,371 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/time_sync/sv_ptp_phase_authority.hpp" +#include "ariec61850/sampled_values/rational_schedule.hpp" + +#include +#include +#include +#include +#include + +using namespace ar::iec61850::time_sync; + +#define CHECK(x) do { if (!(x)) throw std::runtime_error( \ + std::string{"check failed: "} + #x + " line " + \ + std::to_string(__LINE__)); } while (false) + +namespace { + +constexpr std::uint64_t kEpochZero = 1700000000ULL; +constexpr std::uint32_t kPtpfractionNs = 250000000U; + +// Synthetic EMAC PTP second with an independent GPTimer running +80 ppm. +// A true physical validation also requires the actual EMAC TX timestamps. +SvPtpPhaseObservation snapshot( + const std::uint32_t epoch, const std::int32_t phase_us, + const std::uint32_t read_span_us = 8U) { + const std::uint32_t ptp_boundary = static_cast( + static_cast(epoch) * 1000080ULL); + const std::uint32_t read_mid = ptp_boundary + 250020U; + return {read_mid - (read_span_us / 2U), + read_mid + (read_span_us / 2U), + static_cast( + static_cast(ptp_boundary) + phase_us), + epoch, kEpochZero + epoch, kPtpfractionNs}; +} + +void check_phase_sign_and_bounded_corrections() { + SvPtpPhaseAuthority controller; + const auto warm = controller.observe(snapshot(1U, 220)); + CHECK(warm.accepted); + CHECK(!warm.software_tracking); + CHECK(warm.slew_ticks_next_epoch == 0); + CHECK(warm.phase_error_us == 200); // 80ppm * 0.25s = 20us + const auto second = controller.observe(snapshot(2U, 300)); + CHECK(second.accepted); + CHECK(second.phase_error_us == 280); + CHECK(second.slew_ticks_next_epoch < 0); + CHECK(second.slew_ticks_next_epoch >= -400); + CHECK(!second.software_tracking); + // A coarse software START may initially be several milliseconds + // from the PTP epoch. Acquire monotonically, never jump the counter. + SvPtpPhaseAuthority coarse; + CHECK(coarse.observe(snapshot(1U, 9000)).accepted); + const auto coarse_recovery = coarse.observe(snapshot(2U, 9080)); + CHECK(coarse_recovery.accepted); + CHECK(coarse_recovery.slew_ticks_next_epoch == -400); + CHECK(!coarse_recovery.software_tracking); + const auto step = controller.observe(snapshot(3U, 65000)); + CHECK(!step.accepted); + CHECK(step.clock_discontinuity); + CHECK(step.slew_ticks_next_epoch == 0); + CHECK(step.state == SvPtpPhaseState::invalid); + // A fault never retains an integrator/slew command across rearm. + const auto rearm = controller.observe(snapshot(4U, 100)); + CHECK(rearm.accepted); + CHECK(rearm.slew_ticks_next_epoch == 0); +} + +void check_per_second_slew_distribution() { + for (const auto rate : std::array{4000U, 4800U}) { + for (const auto correction : std::array{ + -400, -81, -1, 0, 1, 73, 400}) { + SvBoundedSlewCursor slew; + slew.arm(rate, correction); + ar::iec61850::sampled_values::RationalTickSchedule cadence{ + 1000000ULL, rate}; + std::int64_t total_ticks = 0; + std::int32_t adjusted = 0; + for (std::uint32_t sample = 0U; sample < rate; ++sample) { + const auto adjustment = slew.next_adjustment(); + CHECK(adjustment >= -1 && adjustment <= 1); + const auto base = cadence.next_interval_ticks(); + const auto final_interval = + static_cast(base) + adjustment; + CHECK(final_interval > 0); + total_ticks += final_interval; + adjusted += adjustment; + } + CHECK(adjusted == correction); + CHECK(slew.applied_ticks() == correction); + CHECK(total_ticks == + 1000000LL + static_cast(correction)); + } + SvBoundedSlewCursor invalid; + invalid.arm(0U, 99); + CHECK(invalid.next_adjustment() == 0); + invalid.arm(rate, 3000); // one-tick maximum, cap total slew + for (std::uint32_t i = 0; i < rate; ++i) { + static_cast(invalid.next_adjustment()); + } + CHECK(invalid.applied_ticks() == 400); + } +} + +void check_software_servo_tracks_80ppm_drift_without_counter_reset() { + for (const auto rate : std::array{4000U, 4800U}) { + SvPtpPhaseAuthority controller; + std::int32_t phase_us = 160; + std::int32_t command = 0; + std::int32_t peak_last_100 = 0; + std::uint32_t accepted = 0U; + bool ever_tracking = false; + for (std::uint32_t epoch = 1; epoch <= 480U; ++epoch) { + // The previous epoch's bounded steering is completed before + // the current timestamp is sampled. + phase_us += 80 + command; + const auto result = controller.observe(snapshot(epoch, phase_us)); + CHECK(result.accepted); + CHECK(result.observation_uncertainty_us <= 5U); + CHECK(result.slew_ticks_next_epoch >= -400); + CHECK(result.slew_ticks_next_epoch <= 400); + if (result.software_tracking) ever_tracking = true; + ++accepted; + command = result.slew_ticks_next_epoch; + SvBoundedSlewCursor actual; + actual.arm(rate, command); + std::int32_t distributed = 0; + for (std::uint32_t n = 0U; n < rate; ++n) { + distributed += actual.next_adjustment(); + } + CHECK(distributed == command); + if (epoch > 380U) { + const auto error = result.phase_error_us < 0 + ? -result.phase_error_us : result.phase_error_us; + if (error > peak_last_100) peak_last_100 = error; + } + } + CHECK(accepted == 480U); + CHECK(ever_tracking); + CHECK(peak_last_100 <= 60); + } +} + + +void check_uncertain_brackets_never_claim_software_tracking() { + SvPtpPhaseAuthority tracker; + // Valid control-plane reads may be too uncertain to support a 60 us + // tracking claim. A 400 us PTP read bracket has >=201 us uncertainty; + // the nominal phase is deliberately zero to catch false positives. + for (std::uint32_t epoch = 1U; epoch <= 6U; ++epoch) { + const auto decision = tracker.observe(snapshot(epoch, 20, 400U)); + CHECK(decision.accepted); + CHECK(decision.epoch_second_continuous == (epoch != 1U)); + CHECK(decision.phase_error_us == 0); + CHECK(decision.observation_uncertainty_us >= 201U); + CHECK(decision.state == SvPtpPhaseState::acquiring); + CHECK(!decision.software_tracking); + } + + // Qualification requires four fresh, narrow observations; old + // uncertain observations cannot contribute toward this threshold. + for (std::uint32_t epoch = 7U; epoch <= 9U; ++epoch) { + const auto decision = tracker.observe(snapshot(epoch, 20, 8U)); + CHECK(decision.accepted); + CHECK(!decision.software_tracking); + } + const auto qualified = tracker.observe(snapshot(10U, 20, 8U)); + CHECK(qualified.accepted); + CHECK(qualified.software_tracking); + CHECK(qualified.state == SvPtpPhaseState::tracking); + + // One uncertain sample revokes tracking, even with perfect measured + // phase. The PI/slew remains bounded separately from qualification. + const auto degraded = tracker.observe(snapshot(11U, 20, 400U)); + CHECK(degraded.accepted); + CHECK(!degraded.software_tracking); + CHECK(degraded.state == SvPtpPhaseState::acquiring); +} + +void check_publisher_continuity_gate_reacquisition() { + SvPtpPublisherContinuityGate gate; + SvPtpPhaseAuthority phase; + CHECK(!gate.unchanged(0U, 0U)); + // A new publisher generation cannot reuse the previous phase estimate. + CHECK(!gate.unchanged(1U, 0U)); + CHECK(gate.unchanged(1U, 0U)); + CHECK(phase.observe(snapshot(1U, 10)).accepted); + CHECK(phase.observe(snapshot(2U, 90)).accepted); + + // One missed canonical slot or a failed canonical EMAC send invalidates + // the existing PI integral; observer may only acquire on clean epochs. + CHECK(!gate.unchanged(1U, 1U)); + CHECK(phase.invalidate().slew_ticks_next_epoch == 0); + CHECK(gate.unchanged(1U, 1U)); + const auto cold = phase.observe(snapshot(3U, 100)); + CHECK(cold.accepted); + CHECK(cold.slew_ticks_next_epoch == 0); + + CHECK(!gate.unchanged(2U, 0U)); + CHECK(phase.invalidate().state == SvPtpPhaseState::invalid); + CHECK(gate.unchanged(2U, 0U)); + CHECK(phase.observe(snapshot(1U, 10)).slew_ticks_next_epoch == 0); + // A PTP SOURCE restart, even with valid-looking seconds, must force + // phase controller reacquisition rather than retaining the old PI state. + CHECK(!gate.unchanged(2U, 0U, 3U)); + CHECK(gate.unchanged(2U, 0U, 3U)); + CHECK(!gate.unchanged(2U, 0U, 4U)); + CHECK(gate.unchanged(2U, 0U, 4U)); + CHECK(phase.invalidate().slew_ticks_next_epoch == 0); + CHECK(!gate.unchanged(0U, 0U)); // STOP + CHECK(!gate.unchanged(2U, 0U)); // START must re-acquire +} + +void check_continuity_recovery_under_repeated_loss() { + // Emulate a publisher fault during multiple observation windows. No + // stale correction may survive a drop or a rapid STOP/START transition. + for (const auto rate : std::array{4000U, 4800U}) { + SvPtpPublisherContinuityGate gate; + SvPtpPhaseAuthority phase; + std::uint32_t faults = 0U; + std::uint32_t session = 1U; + std::int32_t oscillator_phase_us = 100; + std::int32_t previous_command = 0; + bool recovered = false; + for (std::uint32_t epoch = 1U; epoch <= 360U; ++epoch) { + oscillator_phase_us += 80 + previous_command; + if (epoch == 55U || epoch == 56U || epoch == 132U || + epoch == 205U || epoch == 206U) { + ++faults; + } + if (epoch == 260U) { + ++session; + faults = 0U; + } + previous_command = 0; + if (!gate.unchanged(session, faults)) { + CHECK(phase.invalidate().slew_ticks_next_epoch == 0); + continue; + } + const auto decision = phase.observe(snapshot(epoch, oscillator_phase_us)); + CHECK(decision.accepted); + CHECK(decision.slew_ticks_next_epoch >= -400); + CHECK(decision.slew_ticks_next_epoch <= 400); + // The command can only ever be distributed by an ISR-safe + // cursor, with each interval changing by at most one tick. + SvBoundedSlewCursor slew; + slew.arm(rate, decision.slew_ticks_next_epoch); + for (std::uint32_t i = 0U; i < rate; ++i) { + const auto delta = slew.next_adjustment(); + CHECK(delta >= -1 && delta <= 1); + } + previous_command = slew.applied_ticks(); + if (epoch > 330U && decision.software_tracking) recovered = true; + } + CHECK(recovered); + } +} + +void check_clock_step_stale_brackets_and_timer_wrap() { + SvPtpPhaseAuthority tracker; + CHECK(!tracker.observe(snapshot(1U, 0, 1200U)).accepted); + CHECK(!tracker.observe(snapshot(2U, 0, 8U)).slew_ticks_next_epoch); + CHECK(tracker.observe(snapshot(3U, 0)).accepted); + // Corrupt the PTP seconds without changing GPTimer; reject promptly. + auto clock_step = snapshot(4U, 0); + clock_step.ptp_seconds += 1U; + const auto step = tracker.observe(clock_step); + CHECK(!step.accepted); + CHECK(step.clock_discontinuity); + CHECK(step.slew_ticks_next_epoch == 0); + // A delayed observation exceeding one epoch is not a license to slew. + const auto restarted = tracker.observe(snapshot(7U, 0)); + CHECK(restarted.accepted); + CHECK(restarted.slew_ticks_next_epoch == 0); + CHECK(!tracker.observe(snapshot(9U, 0)).accepted); + // GPTimer's low 32 bits wrap after about 71 minutes at 1 MHz. + tracker.reset(); + for (std::uint32_t epoch = 4293U; epoch < 4308U; ++epoch) { + const auto result = tracker.observe(snapshot(epoch, 50)); + CHECK(result.accepted); + CHECK(!result.clock_discontinuity); + } + CHECK(tracker.invalidate().slew_ticks_next_epoch == 0); +} + +void check_continuous_ptp_second_binding() { + SvPtpPhaseAuthority authority; + const auto initial = authority.observe(snapshot(10U, 80)); + CHECK(initial.accepted); + CHECK(!initial.epoch_second_continuous); + CHECK(initial.sv_epoch_ptp_second == kEpochZero + 10U); + CHECK(initial.slew_ticks_next_epoch == 0); + + const auto stable = authority.observe(snapshot(11U, 160)); + CHECK(stable.accepted); + CHECK(stable.epoch_second_continuous); + CHECK(stable.sv_epoch_ptp_second == kEpochZero + 11U); + + // One missing epoch invalidates both the phase integrator and the + // PTP-second association. Reacquisition starts from a cold baseline. + const auto skipped = authority.observe(snapshot(13U, 240)); + CHECK(!skipped.accepted); + CHECK(!skipped.epoch_second_continuous); + CHECK(skipped.slew_ticks_next_epoch == 0); + const auto reacquire = authority.observe(snapshot(14U, 320)); + CHECK(reacquire.accepted); + CHECK(!reacquire.epoch_second_continuous); + CHECK(reacquire.slew_ticks_next_epoch == 0); + CHECK(authority.observe(snapshot(15U, 400)).epoch_second_continuous); + + // Valid phase modulo 1 s is not enough: a PTP second step must NOT be + // interpreted as an adjacent SV epoch (neither +1 s nor -1 s). + auto forward = snapshot(16U, 480); + forward.ptp_seconds += 1U; + CHECK(!authority.observe(forward).accepted); + CHECK(!authority.observe(snapshot(17U, 560)).epoch_second_continuous); + CHECK(authority.observe(snapshot(18U, 640)).epoch_second_continuous); + auto backward = snapshot(19U, 720); + backward.ptp_seconds -= 1U; + CHECK(!authority.observe(backward).accepted); + + // Near a PTP second rollover the last SV epoch is correctly labelled + // with the NEXT whole second, not the read's current second. Neither + // rounding nor 32-bit GPTimer wrap may silently alias it. + SvPtpPhaseAuthority boundary; + auto before_second = snapshot(100U, 0); + before_second.ptp_seconds = kEpochZero + 99U; + before_second.ptp_nanoseconds = 999950000U; + before_second.timer_before_ticks = + before_second.last_sv_epoch_ticks + 46U; + before_second.timer_after_ticks = + before_second.last_sv_epoch_ticks + 54U; + const auto baseline = boundary.observe(before_second); + CHECK(baseline.accepted); + CHECK(baseline.phase_error_us == -100); + CHECK(!baseline.epoch_second_continuous); + CHECK(baseline.sv_epoch_ptp_second == kEpochZero + 100U); + const auto next = boundary.observe(snapshot(101U, 80)); + CHECK(next.accepted); + CHECK(next.epoch_second_continuous); + CHECK(next.sv_epoch_ptp_second == kEpochZero + 101U); + + // Keep absolute second labels across 71-minute GPTimer low32 rollover. + SvPtpPhaseAuthority wrap; + for (std::uint32_t epoch = 4293U; epoch < 4333U; ++epoch) { + const auto decision = wrap.observe(snapshot(epoch, 60)); + CHECK(decision.accepted); + CHECK(decision.sv_epoch_ptp_second == kEpochZero + epoch); + CHECK(decision.epoch_second_continuous == (epoch != 4293U)); + } +} + +} // namespace + +int main() { + try { + check_phase_sign_and_bounded_corrections(); + check_uncertain_brackets_never_claim_software_tracking(); + check_per_second_slew_distribution(); + check_software_servo_tracks_80ppm_drift_without_counter_reset(); + check_clock_step_stale_brackets_and_timer_wrap(); + check_publisher_continuity_gate_reacquisition(); + check_continuity_recovery_under_repeated_loss(); + check_continuous_ptp_second_binding(); + std::cout << "SV PTP phase authority 4000/4800 fps regression passed\n"; + return 0; + } catch (const std::exception& e) { + std::cerr << e.what() << '\n'; + return 1; + } +} diff --git a/tests/test_sv_tx_egress_evidence.cpp b/tests/test_sv_tx_egress_evidence.cpp new file mode 100644 index 000000000..0e6c72248 --- /dev/null +++ b/tests/test_sv_tx_egress_evidence.cpp @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/time_sync/sv_tx_egress_evidence.hpp" + +#include +#include +#include +#include + +using namespace ar::iec61850::time_sync; + +#define REQUIRE(x) do { if (!(x)) throw std::runtime_error(\ + std::string{"check failed: "} + #x + " at " + \ + std::to_string(__LINE__)); } while (false) + +namespace { +SvTxEgressObservation make_good( + const std::uint32_t epoch, const std::uint32_t seconds, + const std::uint32_t nanoseconds = 100000U) { + SvTxEgressObservation o{}; + o.smp_cnt = 0U; + o.sample_epoch_generation = epoch; + o.publisher_session_generation = 7U; + o.publisher_fault_generation = 0U; + o.ptp_source_generation = 3U; + o.scheduled_alarm_ticks_low32 = epoch * 1000000U; + o.hw_ptp_seconds = seconds; + o.hw_ptp_nanoseconds = nanoseconds; + o.submit_duration_us = 65U; + o.canonical_tx_success = true; + o.no_missed_slots = true; + o.same_sample_epoch = true; + return o; +} +void check_auto_local_sync_gate_requires_both_clocks_and_fresh_mac() { + SvLocalSyncEvidenceGate gate; + auto o = make_good(32U, 1700000016U, 2000U); + REQUIRE(!gate.qualified(7U, 0U, 3U, 32U, true, 0U)); + gate.observe(o, SvTxEvidenceResult::baseline_established, 2000); + REQUIRE(!gate.qualified(7U, 0U, 3U, 32U, true, 0U)); + gate.observe(o, SvTxEvidenceResult::measured, 2000); + REQUIRE(!gate.qualified(7U, 0U, 3U, 32U, true, 0U)); + o.sample_epoch_generation = 48U; + gate.observe(o, SvTxEvidenceResult::measured, -3000); + REQUIRE(gate.qualified(7U, 0U, 3U, 48U, true, 4U)); + REQUIRE(gate.qualified(7U, 0U, 3U, 65U, true, 4U)); + REQUIRE(!gate.qualified(7U, 0U, 3U, 66U, true, 4U)); + REQUIRE(!gate.qualified(7U, 0U, 3U, 48U, true, 5U)); + REQUIRE(!gate.qualified(7U, 0U, 3U, 48U, false, 0U)); + REQUIRE(!gate.qualified(7U, 1U, 3U, 48U, true, 0U)); + REQUIRE(!gate.qualified(7U, 0U, 4U, 48U, true, 0U)); + REQUIRE(!gate.qualified(8U, 0U, 3U, 48U, true, 0U)); + REQUIRE(!gate.qualified(7U, 0U, 3U, 47U, true, 0U)); + // A new source, bad SV timestamp, skipped frame or TX failure + // immediately revokes the old promotion and requires fresh pairs. + o.sample_epoch_generation = 64U; + gate.observe(o, SvTxEvidenceResult::measured, 5000); + REQUIRE(!gate.qualified(7U, 0U, 3U, 64U, true, 0U)); + gate.observe(o, SvTxEvidenceResult::measured, 1000); + o.sample_epoch_generation = 80U; + gate.observe(o, SvTxEvidenceResult::measured, 1000); + REQUIRE(gate.qualified(7U, 0U, 3U, 80U, true, 0U)); + gate.observe(o, SvTxEvidenceResult::transmit_failed, 1000); + REQUIRE(!gate.qualified(7U, 0U, 3U, 80U, true, 0U)); + o.sample_epoch_generation = 96U; + gate.observe(o, SvTxEvidenceResult::measured, 1000); + o.sample_epoch_generation = 112U; + o.publisher_fault_generation = 1U; + gate.observe(o, SvTxEvidenceResult::measured, 1000); + REQUIRE(!gate.qualified(7U, 1U, 3U, 112U, true, 0U)); + gate.reset(); + REQUIRE(!gate.qualified(7U, 1U, 3U, 112U, true, 0U)); +} + +void check_baseline_and_contiguous_ptp() { + SvTxEgressEvidence e{16U}; + auto o = make_good(16U, 1700000000U, 125000U); + REQUIRE(e.record(o) == SvTxEvidenceResult::baseline_established); + REQUIRE(e.summary().measured == 0U); // one point cannot establish continuity + o = make_good(32U, 1700000015U, 999900000U); + o.scheduled_alarm_ticks_low32 = 0x00000027U; + REQUIRE(e.record(o) == SvTxEvidenceResult::measured); + REQUIRE(e.summary().last_phase_ns == -100000); + REQUIRE(e.summary().last_alarm_ticks_low32 == 0x27U); + REQUIRE(e.summary().min_phase_ns == -100000); + o = make_good(48U, 1700000032U, 0U); + REQUIRE(e.record(o) == SvTxEvidenceResult::measured); + REQUIRE(e.summary().phase_sum_ns == -100000LL); + o = make_good(64U, 1700000048U, 125000U); + REQUIRE(e.record(o) == SvTxEvidenceResult::measured); + REQUIRE(e.summary().max_phase_ns == 125000); + REQUIRE(e.summary().phase_sum_ns == 25000LL); + REQUIRE(e.summary().attempted == 4U); + REQUIRE(e.summary().measured == 3U); +} +void check_clock_steps_recovery_and_session() { + SvTxEgressEvidence e; + REQUIRE(e.record(make_good(16U, 1700000000U)) == + SvTxEvidenceResult::baseline_established); + // +1 sec PTP clock step despite perfect-looking within-second phase. + REQUIRE(e.record(make_good(32U, 1700000017U)) == + SvTxEvidenceResult::ptp_clock_discontinuity); + REQUIRE(e.summary().measured == 0U); + REQUIRE(e.summary().ptp_clock_discontinuities == 1U); + REQUIRE(e.record(make_good(48U, 1700000033U)) == + SvTxEvidenceResult::measured); // two coherent post-step epochs + auto restarted = make_good(64U, 1700000049U); + restarted.publisher_session_generation = 8U; + REQUIRE(e.record(restarted) == SvTxEvidenceResult::publisher_discontinuity); + restarted.sample_epoch_generation = 80U; + restarted.hw_ptp_seconds = 1700000065U; + REQUIRE(e.record(restarted) == SvTxEvidenceResult::measured); + restarted.sample_epoch_generation = 96U; + restarted.hw_ptp_seconds = 1700000081U; + ++restarted.publisher_fault_generation; // lost a canonical frame mid-window + REQUIRE(e.record(restarted) == SvTxEvidenceResult::publisher_discontinuity); + REQUIRE(e.summary().publisher_discontinuities == 2U); + restarted.sample_epoch_generation = 112U; + restarted.hw_ptp_seconds = 1700000097U; + REQUIRE(e.record(restarted) == SvTxEvidenceResult::measured); + // Detect 16-second sample epoch discontinuity even if PTP time looks fine. + restarted.sample_epoch_generation = 144U; + restarted.hw_ptp_seconds = 1700000113U; + REQUIRE(e.record(restarted) == SvTxEvidenceResult::publisher_discontinuity); + // Restarted PTP source can retain plausible seconds; its generation + // nevertheless must discard the previous comparison window. + restarted.sample_epoch_generation = 160U; + restarted.hw_ptp_seconds = 1700000129U; + ++restarted.ptp_source_generation; + REQUIRE(e.record(restarted) == SvTxEvidenceResult::ptp_clock_discontinuity); + REQUIRE(e.summary().ptp_source_changes == 1U); + restarted.sample_epoch_generation = 176U; + restarted.hw_ptp_seconds = 1700000145U; + REQUIRE(e.record(restarted) == SvTxEvidenceResult::measured); +} +void check_invalid_evidence_requires_rebaseline() { + SvTxEgressEvidence e; + auto o = make_good(16U, 1700000000U); + REQUIRE(e.record(o) == SvTxEvidenceResult::baseline_established); + o = make_good(32U, 1700000016U); + o.ptp_source_generation = 0U; + REQUIRE(e.record(o) == SvTxEvidenceResult::not_a_qualified_epoch); + o = make_good(48U, 1700000032U); + REQUIRE(e.record(o) == SvTxEvidenceResult::baseline_established); + o = make_good(64U, 1700000048U); + o.hw_ptp_seconds = 0U; + REQUIRE(e.record(o) == SvTxEvidenceResult::missing_hw_timestamp); + o = make_good(80U, 1700000064U); + REQUIRE(e.record(o) == SvTxEvidenceResult::baseline_established); + o = make_good(96U, 1700000080U); + o.submit_duration_us = 900U; + o.no_missed_slots = false; // descriptor blocking exceeded slot + REQUIRE(e.record(o) == SvTxEvidenceResult::not_a_qualified_epoch); + REQUIRE(e.summary().max_submit_duration_us == 900U); + o = make_good(112U, 1700000096U); + REQUIRE(e.record(o) == SvTxEvidenceResult::baseline_established); + o = make_good(128U, 1700000112U); + o.canonical_tx_success = false; + REQUIRE(e.record(o) == SvTxEvidenceResult::transmit_failed); + o = make_good(144U, 1700000128U); + REQUIRE(e.record(o) == SvTxEvidenceResult::baseline_established); + o = make_good(160U, 1700000144U); + REQUIRE(e.record(o) == SvTxEvidenceResult::measured); + REQUIRE(e.summary().measured == 1U); + REQUIRE(e.summary().missing_hw_timestamp == 1U); + REQUIRE(e.summary().failed_transmit == 1U); + REQUIRE(e.summary().rejected_epoch == 2U); + e.reset(); + REQUIRE(e.summary().attempted == 0U); + REQUIRE(e.summary().measured == 0U); +} +void check_counter_wrap_and_signed_negative_phase() { + SvTxEgressEvidence e; + auto o = make_good(0xfffffff8U, 1700000099U, 999999900U); + REQUIRE(e.record(o) == SvTxEvidenceResult::baseline_established); + o = make_good(8U, 1700000115U, 999999900U); + REQUIRE(e.record(o) == SvTxEvidenceResult::measured); + REQUIRE(e.summary().last_phase_ns == -100); + // A 2-second backward clock step cannot silently look like a good phase. + o = make_good(24U, 1700000129U, 999999900U); + REQUIRE(e.record(o) == SvTxEvidenceResult::ptp_clock_discontinuity); + SvTxEgressEvidence invalid{0U}; + REQUIRE(invalid.record(make_good(16U, 1700000000U)) == + SvTxEvidenceResult::not_a_qualified_epoch); +} +} + +int main() { + try { + check_auto_local_sync_gate_requires_both_clocks_and_fresh_mac(); + check_baseline_and_contiguous_ptp(); + check_clock_steps_recovery_and_session(); + check_invalid_evidence_requires_rebaseline(); + check_counter_wrap_and_signed_negative_phase(); + std::cout << "SV descriptor TX evidence temporal continuity tests passed\n"; + return 0; + } catch (const std::exception& e) { + std::cerr << e.what() << "\n"; + return 1; + } +} diff --git a/tools/ptp_sv_wire_audit.cpp b/tools/ptp_sv_wire_audit.cpp new file mode 100644 index 000000000..abc970c8f --- /dev/null +++ b/tools/ptp_sv_wire_audit.cpp @@ -0,0 +1,468 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Read-only, standards-oriented PTP + Sampled Values PCAP evidence. +// Reuses existing ARIEC61850 codecs and stream supervisor, not a second parser. +#include "ariec61850/capture/pcap.hpp" +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/sampled_values/frame_codec.hpp" +#include "ariec61850/sampled_values/stream_supervisor.hpp" +#include "ariec61850/time_sync/ptp.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +namespace capture = ar::iec61850::capture; +namespace ethernet = ar::iec61850::ethernet; +namespace sv = ar::iec61850::sampled_values; +namespace ptp = ar::iec61850::time_sync; + +struct AuditOptions final { + std::string sv_id; + std::uint16_t app_id{}; + std::uint16_t counter_modulus{}; + std::uint8_t ptp_domain{}; +}; + +struct Evidence final { + std::uint64_t packets{}; + std::uint64_t malformed{}; + std::uint64_t unrelated{}; + std::uint64_t selected_sv_frames{}; + std::uint64_t selected_asdus{}; + std::uint64_t ignored_sv_frames{}; + std::uint64_t sv_source_conflicts{}; + std::uint64_t ptp_domain_mismatches{}; + std::uint64_t ptp_source_conflicts{}; + std::uint64_t ptp_announce{}; + std::uint64_t ptp_sync{}; + std::uint64_t ptp_two_step_sync{}; + std::uint64_t ptp_follow_up{}; + std::uint64_t ptp_follow_up_matched{}; + std::uint64_t ptp_follow_up_orphan{}; + std::uint64_t ptp_follow_up_missing{}; + std::uint64_t ptp_pdelay_response{}; + std::array smp_synch_counts{}; + std::uint64_t counter_zero_observations{}; + std::int64_t counter_zero_capture_phase_min_us{1000000}; + std::int64_t counter_zero_capture_phase_max_us{-1000000}; +}; + +class CaptureAudit final { +public: + explicit CaptureAudit(const AuditOptions& options) + : options_(options), + supervisor_(sv::StreamExpectation{ + options.sv_id, std::nullopt, std::nullopt, options.counter_modulus}) {} + + void observe(const capture::PcapPacket& packet) { + ++results_.packets; + ethernet::EthernetFrame link; + if (!ethernet::EthernetFrameCodec::try_decode(packet.frame, link)) { + ++results_.malformed; + return; + } + if (link.ether_type == ethernet::ptp_ethertype) { + ptp::PtpFrame frame; + if (!ptp::PtpCodec::try_parse_ethernet_frame(packet.frame, frame)) { + ++results_.malformed; + return; + } + if (frame.header.domain_number != options_.ptp_domain) { + ++results_.ptp_domain_mismatches; + return; + } + // Announce/Sync/FollowUp must belong to ONE selected clock + // identity. A rogue GM never completes the current Sync pair. + const auto message_type = frame.header.message_type; + if (message_type == ptp::PtpMessageType::announce || + message_type == ptp::PtpMessageType::sync || + message_type == ptp::PtpMessageType::follow_up) { + if (!ptp_source_.has_value()) { + ptp_source_ = frame.header.source_port_identity; + } else if (*ptp_source_ != frame.header.source_port_identity) { + ++results_.ptp_source_conflicts; + return; + } + } + switch (message_type) { + case ptp::PtpMessageType::announce: + ++results_.ptp_announce; + break; + case ptp::PtpMessageType::sync: + ++results_.ptp_sync; + if (frame.header.is_two_step()) { + ++results_.ptp_two_step_sync; + if (pending_sync_sequence_.has_value()) { + ++results_.ptp_follow_up_missing; + } + pending_sync_sequence_ = frame.header.sequence_id; + } + break; + case ptp::PtpMessageType::follow_up: + ++results_.ptp_follow_up; + if (pending_sync_sequence_ == frame.header.sequence_id) { + ++results_.ptp_follow_up_matched; + pending_sync_sequence_.reset(); + } else { + ++results_.ptp_follow_up_orphan; + } + break; + case ptp::PtpMessageType::pdelay_resp: + ++results_.ptp_pdelay_response; + break; + default: break; + } + return; + } + if (link.ether_type != ethernet::sampled_values_ethertype) { + ++results_.unrelated; + return; + } + + sv::SampledValuesFrame frame; + if (!sv::SampledValuesFrameCodec::try_decode(packet.frame, frame)) { + ++results_.malformed; + return; + } + bool selected = false; + for (const auto& asdu : frame.pdu.asdus) { + if (frame.app_id != options_.app_id || + asdu.sv_id != options_.sv_id) { + continue; + } + selected = true; + if (!sv_source_.has_value()) { + sv_source_ = frame.source; + } else if (*sv_source_ != frame.source) { + ++results_.sv_source_conflicts; + continue; // Never mix counter histories from different publishers. + } + const auto observation = supervisor_.observe(asdu); + (void)observation; + ++results_.selected_asdus; + const auto synch = asdu.sample_synchronization; + ++results_.smp_synch_counts[synch <= 2U ? synch : 3U]; + + // A host-captured packet's timestamp is NOT an IEEE1588 MAC + // egress timestamp. Record the coarse offset for diagnostics only. + if (asdu.sample_count == 0U) { + const auto observed_us = + std::chrono::duration_cast( + packet.timestamp.time_since_epoch()).count(); + std::int64_t fractional_us = static_cast(observed_us % 1000000LL); + if (fractional_us < 0) fractional_us += 1000000LL; + if (fractional_us >= 500000LL) fractional_us -= 1000000LL; + ++results_.counter_zero_observations; + results_.counter_zero_capture_phase_min_us = std::min( + results_.counter_zero_capture_phase_min_us, fractional_us); + results_.counter_zero_capture_phase_max_us = std::max( + results_.counter_zero_capture_phase_max_us, fractional_us); + } + } + if (selected) ++results_.selected_sv_frames; + else ++results_.ignored_sv_frames; + } + + [[nodiscard]] const Evidence& evidence() const noexcept { return results_; } + [[nodiscard]] const sv::StreamStatistics& stream() const noexcept { + return supervisor_.statistics(); + } + [[nodiscard]] bool ptp_visibility_complete() const noexcept { + return results_.ptp_announce > 0U && results_.ptp_sync > 0U && + results_.ptp_source_conflicts == 0U && + results_.ptp_follow_up_orphan == 0U && + results_.ptp_follow_up_missing == 0U && + !pending_sync_sequence_.has_value() && + (results_.ptp_two_step_sync == 0U || + results_.ptp_follow_up_matched == results_.ptp_two_step_sync); + } + [[nodiscard]] bool wire_consistent() const noexcept { + const auto& s = stream(); + return results_.selected_asdus > 0U && results_.malformed == 0U && + results_.sv_source_conflicts == 0U && + results_.smp_synch_counts[3U] == 0U && + s.gaps == 0U && s.duplicates == 0U && s.out_of_order == 0U && + s.identity_mismatches == 0U && s.configuration_changes == 0U; + } +private: + AuditOptions options_; + sv::SampledValuesStreamSupervisor supervisor_; + Evidence results_{}; + std::optional sv_source_; + std::optional ptp_source_; + std::optional pending_sync_sequence_; +}; + +[[nodiscard]] unsigned parse_unsigned( + const std::string_view text, const unsigned maximum, const char* label) { + if (text.empty()) throw std::invalid_argument(std::string{label} + ": missing value"); + std::size_t consumed = 0U; + const auto value = std::stoul(std::string{text}, &consumed, 0); + if (consumed != text.size() || value > maximum) { + throw std::invalid_argument(std::string{label} + ": out of range"); + } + return static_cast(value); +} + +void print_usage() { + std::cout << + "Usage: ariec61850_ptp_sv_wire_audit " + " --sv-id --app-id <0xNNNN> --counter-modulus " + " --ptp-domain [--json]\n" + " ariec61850_ptp_sv_wire_audit --self-test\n" + "Wire evidence only. Host PCAP timestamps never establish IEEE1588" + " clock lock, UTC traceability, MAC TX phase, or IED alarm clearance.\n"; +} + +void display(const CaptureAudit& audit, const bool json) { + const auto& a = audit.evidence(); + const auto& s = audit.stream(); + const bool wire = audit.wire_consistent(); + const bool ptp_visible = audit.ptp_visibility_complete(); + if (json) { + std::cout << "{" + << "\"evidence\":\"wire_only_unqualified\"," + << "\"packets\":" << a.packets << "," + << "\"ptpAnnounce\":" << a.ptp_announce << "," + << "\"ptpSync\":" << a.ptp_sync << "," + << "\"ptpFollowUp\":" << a.ptp_follow_up << "," + << "\"ptpFollowUpMatched\":" << a.ptp_follow_up_matched << "," + << "\"ptpFollowUpOrphan\":" << a.ptp_follow_up_orphan << "," + << "\"ptpFollowUpMissing\":" << a.ptp_follow_up_missing << "," + << "\"ptpPdelayResp\":" << a.ptp_pdelay_response << "," + << "\"ptpDomainMismatches\":" << a.ptp_domain_mismatches << "," + << "\"ptpSourceConflicts\":" << a.ptp_source_conflicts << "," + << "\"svFrames\":" << a.selected_sv_frames << "," + << "\"svAsdus\":" << a.selected_asdus << "," + << "\"ignoredSvFrames\":" << a.ignored_sv_frames << "," + << "\"svSourceConflicts\":" << a.sv_source_conflicts << "," + << "\"smpSynch0\":" << a.smp_synch_counts[0] << "," + << "\"smpSynch1\":" << a.smp_synch_counts[1] << "," + << "\"smpSynch2\":" << a.smp_synch_counts[2] << "," + << "\"smpSynchUnknown\":" << a.smp_synch_counts[3] << "," + << "\"normalWraps\":" << s.normal_wraps << "," + << "\"gaps\":" << s.gaps << "," + << "\"duplicates\":" << s.duplicates << "," + << "\"outOfOrder\":" << s.out_of_order << "," + << "\"missingSamples\":" << s.missing_samples << "," + << "\"configurationChanges\":" << s.configuration_changes << "," + << "\"malformed\":" << a.malformed << "," + << "\"counterZeroCaptureObservations\":" << a.counter_zero_observations << "," + << "\"counterZeroHostCapturePhaseMinUs\":" + << (a.counter_zero_observations != 0U ? + a.counter_zero_capture_phase_min_us : 0LL) << "," + << "\"counterZeroHostCapturePhaseMaxUs\":" + << (a.counter_zero_observations != 0U ? + a.counter_zero_capture_phase_max_us : 0LL) << "," + << "\"ptpPacketsVisible\":" << std::boolalpha << ptp_visible << "," + << "\"svWireConsistent\":" << wire << "," + << "\"ptpClockSynchronized\":null," + << "\"svPtpPhaseQualified\":null," + << "\"iedAlarmFree\":null}" + << "\n"; + } else { + std::cout << "PTP Announce/Sync/FollowUp: " + << a.ptp_announce << "/" << a.ptp_sync << "/" + << a.ptp_follow_up << " (matched=" + << a.ptp_follow_up_matched << ", orphan=" + << a.ptp_follow_up_orphan << ", missing=" + << a.ptp_follow_up_missing << ") | PdelayResp=" << a.ptp_pdelay_response + << " | domain mismatch=" << a.ptp_domain_mismatches + << " | source conflicts=" << a.ptp_source_conflicts << "\n" + << "SV selected frames/asdus: " << a.selected_sv_frames + << "/" << a.selected_asdus + << " | smpSynch [0,1,2,other]: " + << a.smp_synch_counts[0] << "," + << a.smp_synch_counts[1] << "," + << a.smp_synch_counts[2] << "," + << a.smp_synch_counts[3] << "\n" + << "smpCnt wraps/gaps/duplicates/out-of-order/missing: " + << s.normal_wraps << "/" << s.gaps << "/" << s.duplicates + << "/" << s.out_of_order << "/" << s.missing_samples << "\n" + << "Observed wire consistency: " << (wire ? "PASS" : "FAIL") + << " | PTP source packets visible: " + << (ptp_visible ? "YES" : "INCOMPLETE") << "\n" + << "PTP lock / SV physical phase / IED alarms: NOT MEASURED\n"; + } +} + +void self_test() { + const AuditOptions opts{"LAB.SV01", 0x4000U, 4U, 0U}; + std::stringstream bytes(std::ios::in | std::ios::out | std::ios::binary); + capture::PcapWriter writer(bytes); + auto t = std::chrono::system_clock::time_point{ + std::chrono::seconds{1700000000LL}}; + const std::array ptp_mac{ + 0x02U, 0x10U, 0x20U, 0x30U, 0x40U, 0x50U}; + ptp::PtpBuildOptions config; + config.domain_number = 0U; + config.source_port_identity.clock_identity = + {0x02U, 0x10U, 0x20U, 0xFFU, 0xFEU, 0x30U, 0x40U, 0x50U}; + config.source_port_identity.port_number = 1U; + config.grandmaster_identity = config.source_port_identity.clock_identity; + config.timestamp = {1700000000ULL, 0U}; + const auto emit_ptp = [&](const std::vector& pdu) { + const auto ethernet_bytes = ptp::PtpCodec::build_ethernet_frame( + ptp::ptp_general_multicast_mac, ptp_mac, pdu); + if (ethernet_bytes.empty()) throw std::runtime_error("self-test PTP encode"); + writer.write_packet(t, ethernet_bytes); + t += std::chrono::microseconds{100}; + }; + emit_ptp(ptp::PtpCodec::build_announce(config)); + emit_ptp(ptp::PtpCodec::build_sync(config)); + emit_ptp(ptp::PtpCodec::build_follow_up(config)); + + sv::SampledValuesFrame frame; + frame.destination = ethernet::MacAddress::parse("01:0C:CD:04:00:01"); + frame.source = ethernet::MacAddress::parse("02:AA:BB:CC:DD:EE"); + frame.app_id = opts.app_id; + sv::SampledValueAsdu asdu; + asdu.sv_id = opts.sv_id; + asdu.data_set_reference = "IED1/LLN0$DS1"; + asdu.configuration_revision = 1U; + asdu.sample_payload.assign(64U, 0U); + frame.pdu.asdus.push_back(asdu); + const auto emit_sv = [&](const std::uint16_t count) { + frame.pdu.asdus[0].sample_count = count; + writer.write_packet(t, sv::SampledValuesFrameCodec::encode(frame)); + t += std::chrono::microseconds{250}; + }; + emit_sv(2U); + emit_sv(3U); + emit_sv(0U); + emit_sv(1U); + // An unrelated vendor stream cannot contaminate the selected smpCnt. + frame.pdu.asdus[0].sv_id = "OTHER.SV"; + emit_sv(3U); + frame.pdu.asdus[0].sv_id = opts.sv_id; + emit_sv(3U); // missing expected sample 2 -> detect one gap. + + bytes.seekg(0); + const auto packets = capture::PcapReader::read_all(bytes); + if (packets.size() < 2U) throw std::runtime_error("PCAP fixture too small"); + // Both APIs MUST return identical observations. Reader remains reusable + // after a seek, with no retained packet list in the streaming caller. + bytes.clear(); + bytes.seekg(0); + CaptureAudit streaming(opts); + capture::PcapReader::for_each_packet( + bytes, [&streaming](capture::PcapPacket&& packet) { + streaming.observe(packet); + }); + if (streaming.stream().gaps != 1U || + streaming.stream().normal_wraps != 1U || + streaming.evidence().selected_asdus != 5U) { + throw std::runtime_error("Streaming PCAP regression diverged from read_all"); + } + CaptureAudit clean(opts); + for (std::size_t i = 0U; i + 1U < packets.size(); ++i) { + clean.observe(packets[i]); + } + if (!clean.ptp_visibility_complete() || !clean.wire_consistent() || + clean.stream().normal_wraps != 1U || + clean.evidence().ignored_sv_frames != 1U) { + throw std::runtime_error("Clean interleaved PTP/SV PCAP rejected"); + } + // A Follow_Up with a different sequenceId must NEVER qualify a Sync. + auto wrong_follow_up = packets; + auto mismatched_config = config; + mismatched_config.sequence_id = 0x4321U; + wrong_follow_up[2U].frame = ptp::PtpCodec::build_ethernet_frame( + ptp::ptp_general_multicast_mac, ptp_mac, + ptp::PtpCodec::build_follow_up(mismatched_config)); + CaptureAudit mismatched(opts); + for (const auto& packet : wrong_follow_up) mismatched.observe(packet); + if (mismatched.ptp_visibility_complete() || + mismatched.evidence().ptp_follow_up_orphan != 1U || + mismatched.evidence().ptp_follow_up_matched != 0U) { + throw std::runtime_error("Mismatched PTP Follow_Up accepted"); + } + // Missing Follow_Up is not repaired by arbitrary packet visibility. + CaptureAudit missing_followup(opts); + for (std::size_t i = 0U; i < packets.size(); ++i) { + if (i != 2U) missing_followup.observe(packets[i]); + } + if (missing_followup.ptp_visibility_complete()) { + throw std::runtime_error("Missing PTP Follow_Up accepted"); + } + CaptureAudit audit(opts); + for (const auto& p : packets) audit.observe(p); + const auto& e = audit.evidence(); + const auto& s = audit.stream(); + if (e.ptp_announce != 1U || e.ptp_sync != 1U || + e.ptp_follow_up != 1U || e.ptp_follow_up_matched != 1U || + e.ptp_follow_up_orphan != 0U || e.selected_asdus != 5U || + e.ignored_sv_frames != 1U || s.normal_wraps != 1U || + s.gaps != 1U || s.missing_samples != 1U || + !audit.ptp_visibility_complete() || audit.wire_consistent()) { + throw std::runtime_error("PTP+SV PCAP self-test failed"); + } + std::cout << "PTP+SV combined offline PCAP regression PASS\n"; +} + +} // namespace + +int main(const int argc, char** argv) { + try { + if (argc == 2 && std::string_view{argv[1]} == "--self-test") { + self_test(); + return 0; + } + if (argc < 2) { + print_usage(); + return 64; + } + const std::filesystem::path path{argv[1]}; + AuditOptions options; + bool json = false; + bool has_app = false, has_modulus = false, has_domain = false; + for (int i = 2; i < argc; ++i) { + const std::string_view key{argv[i]}; + if (key == "--json") { json = true; continue; } + if (i + 1 >= argc) throw std::invalid_argument("Missing option value"); + const std::string_view value{argv[++i]}; + if (key == "--sv-id") options.sv_id = std::string{value}; + else if (key == "--app-id") { + options.app_id = static_cast( + parse_unsigned(value, 65535U, "--app-id")); + has_app = true; + } else if (key == "--counter-modulus") { + options.counter_modulus = static_cast( + parse_unsigned(value, 65535U, "--counter-modulus")); + has_modulus = true; + } else if (key == "--ptp-domain") { + options.ptp_domain = static_cast( + parse_unsigned(value, 255U, "--ptp-domain")); + has_domain = true; + } else throw std::invalid_argument("Unknown argument: " + std::string{key}); + } + if (options.sv_id.empty() || !has_app || !has_modulus || + options.counter_modulus < 2U || !has_domain) { + print_usage(); + return 64; + } + CaptureAudit audit(options); + capture::PcapReader::for_each_packet( + path, [&audit](capture::PcapPacket&& packet) { + audit.observe(packet); + }); + display(audit, json); + // A nonzero exit means a missing/anomalous software *wire* fixture; + // exit=0 NEVER means an IED's PTP clock has physically synchronized. + return audit.wire_consistent() && audit.ptp_visibility_complete() ? 0 : 2; + } catch (const std::exception& error) { + std::cerr << "PTP+SV wire audit: " << error.what() << "\n"; + return 1; + } +} From fc5d67677531be2f12de5281bca14246a2bbfadc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 20:16:50 +0700 Subject: [PATCH 02/15] ci(firmware): build traceable SOURCE phase-servo + MAC evidence with Studio preview opt-in --- .github/workflows/esp32p4-smv-injector.yml | 44 ++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index 5921793e0..73dba760b 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -2,6 +2,12 @@ name: ESP32-P4 SMV Injector on: workflow_call: + inputs: + phase_sync_preview: + description: "Build PTP-to-SV measured-local AUTO candidate only for Studio preview" + type: boolean + default: false + required: false workflow_dispatch: push: branches: [main] @@ -106,9 +112,36 @@ jobs: echo "ARSTACK_FIRMWARE_BUILD_ID=${ARSTACK_SOURCE_COMMIT:0:16}" >> "$GITHUB_ENV" echo "Exact firmware build identity pinned to ${ARSTACK_SOURCE_COMMIT:0:16}" + - name: Enable PTP-SV measured AUTO preview profile + if: ${{ inputs.phase_sync_preview == true }} + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + cfg = Path('sdkconfig.defaults') + content = cfg.read_text(encoding='utf-8') + original = 'CONFIG_AR_SMV_BROADCAST_MIRROR=y' + assert content.count(original) == 1, 'expected controlled mirror policy' + content = content.replace(original, '# CONFIG_AR_SMV_BROADCAST_MIRROR is not set') + content += ('\n# SOURCE PTP-SV AUTO phase qualification - preview only\n' + 'CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y\n' + 'CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL=y\n') + cfg.write_text(content, encoding='utf-8') + PY + - name: Select ESP32-P4 target run: source /opt/esp/idf/export.sh && idf.py set-target esp32p4 + - name: Verify phase-qualified Studio preview is the actual compiled profile + if: ${{ inputs.phase_sync_preview == true }} + run: | + set -euo pipefail + grep -qx 'CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y' sdkconfig + grep -qx 'CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL=y' sdkconfig + grep -qx '# CONFIG_AR_SMV_BROADCAST_MIRROR is not set' sdkconfig + grep -qx 'CONFIG_AR_PTP_OPERATING_ROLE=0' sdkconfig + echo 'SOURCE local AUTO preview: servo ON; canonical MAC TX evidence ON; mirror OFF' + - name: Verify P0 pre-v3 silicon policy run: | set -euo pipefail @@ -194,7 +227,13 @@ jobs: import os import re import sys + from pathlib import Path image, sha256, source_commit = sys.argv[1:] + cfg = Path('sdkconfig').read_text(encoding='utf-8') + servo = 'CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y' in cfg + mac_evidence = 'CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL=y' in cfg + no_mirror = '# CONFIG_AR_SMV_BROADCAST_MIRROR is not set' in cfg + candidate = servo and mac_evidence and no_mirror if not re.fullmatch(r"[0-9a-f]{40}", source_commit): raise SystemExit("Firmware source SHA unavailable/invalid; refusing a zero-provenance package") manifest = { @@ -211,6 +250,11 @@ jobs: "espIdf": "5.5.4", "sourceCommit": source_commit, "buildId": source_commit[:16], + "phaseSyncPreview": candidate, + "phaseServoEnabled": servo, + "canonicalMacTxEvidenceEnabled": mac_evidence, + "broadcastMirrorEnabled": not no_mirror, + "smpSynchQualifiedAtBuild": False, } with open("build/firmware-manifest.json", "w", encoding="utf-8", newline="\n") as handle: json.dump(manifest, handle, indent=2, sort_keys=True) From 5c168c0767c978cbabfffd7d8e3f25b62948c42f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 20:17:11 +0700 Subject: [PATCH 03/15] ci(studio): require exact-matched M3C.5 phase-evidence firmware in Windows native Preview --- .github/workflows/arstack-studio-native-preview.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index cc16f5d44..fb92d1db8 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -19,6 +19,10 @@ jobs: # rather than taking an older public firmware that lacks PROFILE-BINARY-V1. firmware: uses: ./.github/workflows/esp32p4-smv-injector.yml + with: + # Only the GUI Preview installer opts into measured local phase sync. + # Stable release workflow keeps ordinary firmware until board validation. + phase_sync_preview: true windows-preview: needs: firmware @@ -95,6 +99,12 @@ jobs: throw 'Matching firmware job did not provide its manifest' } $manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json + if ($manifest.phaseSyncPreview -ne $true -or + $manifest.phaseServoEnabled -ne $true -or + $manifest.canonicalMacTxEvidenceEnabled -ne $true -or + $manifest.broadcastMirrorEnabled -ne $false) { + throw 'Studio preview refuses old/default SMV firmware without measured PTP-SV phase and MAC TX evidence' + } $expectedCapabilities = @('SMV-4I4V', 'PROFILE', 'PROFILE-BINARY-V1', 'LIVE-SETPOINTS', 'SESSION-LEASE', 'PTP-P2', 'SMPSYNCH-AUTO') foreach ($capability in $expectedCapabilities) { if ($manifest.capabilities -notcontains $capability) { throw "PR firmware lacks capability: $capability" } From 403a829cce37576082ab4303b42faaf1b7373411 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 20:17:33 +0700 Subject: [PATCH 04/15] fix(studio): make pending setpoints visible and remove lab-only Advanced wording --- apps/arstack_studio/qml/ConfigurationHub.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/arstack_studio/qml/ConfigurationHub.qml b/apps/arstack_studio/qml/ConfigurationHub.qml index 0b22527d1..fecaa51a3 100644 --- a/apps/arstack_studio/qml/ConfigurationHub.qml +++ b/apps/arstack_studio/qml/ConfigurationHub.qml @@ -79,7 +79,7 @@ Item { font.weight: Font.DemiBold } Label { - text: "Lab simulation controls · configure one task at a time" + text: "Signals, firmware, PTP and device settings" color: hub.theme.muted font.family: hub.uiFont font.pixelSize: 11 From b23ec883d3f8e21f000dfad55d5840b87741d698 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 20:17:36 +0700 Subject: [PATCH 05/15] fix(studio): make pending setpoints visible and remove lab-only Advanced wording --- apps/arstack_studio/qml/Main.qml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index b419ddd7c..3677aa30e 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -642,8 +642,11 @@ ApplicationWindow { } Rectangle { width: 1; height: 12; color: studioTheme.lineSoft } Label { - text: "4I + 4V · 4000 samples/s" - color: studioTheme.muted + text: root.pendingApply && !workflowBar.injectionRunning + ? "4I + 4V · Changes sent on Start" + : "4I + 4V · 4000 samples/s" + color: root.pendingApply && !workflowBar.injectionRunning + ? studioTheme.amber : studioTheme.muted font.family: root.uiFont font.pixelSize: 9 } From 81999d943e245dd16212ddedfaf66965c90e24dc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 20:17:38 +0700 Subject: [PATCH 06/15] fix(studio): make pending setpoints visible and remove lab-only Advanced wording --- apps/arstack_studio/tests/check_operator_workspace.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 48f76a6fc..fc1d559dd 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -30,6 +30,12 @@ def main() -> None: tray = source("StudioTimingTray.qml") expert = source("PtpExpertPanel.qml") config = source("ConfigurationHub.qml") + require("Lab simulation controls" not in config + and "Signals, firmware, PTP and device settings" in config, + "Advanced must not imply this is only a simulated lab tool") + require("Changes sent on Start" in main_qml + and "root.pendingApply && !workflowBar.injectionRunning" in main_qml, + "STOPPED waveform edits must distinguish draft from sent setpoints") matrix = source("SignalMatrix.qml") menu = source("StudioActionMenu.qml") cmake = (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8") From 180749e1afd9b5816fdd9c61d509d10ad24e7c61 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 20:21:01 +0700 Subject: [PATCH 07/15] ci(ptp): enforce shipped 1s IEC 61850-9-3 Sync profile instead of stale 250ms --- .github/workflows/esp32p4-smv-injector.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index 73dba760b..4b3d3b6ed 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -157,7 +157,7 @@ jobs: grep -qx 'CONFIG_AR_PTP_DOMAIN=0' sdkconfig grep -qx 'CONFIG_AR_PTP_TRANSPORT_SPECIFIC=0' sdkconfig grep -qx 'CONFIG_AR_PTP_RESPOND_PDELAY=y' sdkconfig - grep -qx 'CONFIG_AR_PTP_SYNC_INTERVAL_MS=250' sdkconfig + grep -qx 'CONFIG_AR_PTP_SYNC_INTERVAL_MS=1000' sdkconfig grep -qx 'CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS=1000' sdkconfig echo 'Production timing profile: PASS · serialized shared EMAC TX · PTP-P2 LAB_SOURCE · HW timestamp adapter compiled' From f3194a03f7987a01f12e649c467b0b7d58b4c4a3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 20:21:16 +0700 Subject: [PATCH 08/15] ci(ptp): verify bounded SOURCE readiness freshness rather than obsolete raw ready flag --- .github/workflows/esp32p4-ptp-lab.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index 66031bc18..f9eb0b1f4 100644 --- a/.github/workflows/esp32p4-ptp-lab.yml +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -93,7 +93,7 @@ jobs: 'g_announce_sent.load', 'g_sync_sent.load', 'g_follow_up_sent.load', - 'return g_ptp_ready.load', + 'return source_has_fresh_evidence();', ] combined = eth + '\n' + ptp missing = [token for token in required if token not in combined] From cbc81811cecf033328eb4470a96e5f1b4a6591b0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 20:22:16 +0700 Subject: [PATCH 09/15] ci(ptp): compile every newly ported regression target on MSVC before CTest --- .github/workflows/ptp-core-ci.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/ptp-core-ci.yml b/.github/workflows/ptp-core-ci.yml index cfbd312be..faa4165c0 100644 --- a/.github/workflows/ptp-core-ci.yml +++ b/.github/workflows/ptp-core-ci.yml @@ -121,6 +121,15 @@ jobs: Build-Target 'smp_synch_wire_offset_tests' Build-Target 'ptp_discipline_tests' Build-Target 'ptp_p2_hardening_tests' + Build-Target 'ptp_power_profile_tests' + Build-Target 'sv_ptp_epoch_tests' + Build-Target 'ptp_control_cancellation_tests' + Build-Target 'ptp_emac_role_ownership_tests' + Build-Target 'ptp_canonical_authority_tests' + Build-Target 'ptp_receiver_clock_authority_tests' + Build-Target 'sv_ptp_clock_lease_tests' + Build-Target 'sv_ptp_phase_authority_tests' + Build-Target 'sv_tx_egress_evidence_tests' Build-Target 'ptp_profile_analyze' - name: Run PTP regression and analyzer smoke tests on MSVC From 65f3f0cdf751dc4ab8351f9fc6ea14ccee9157ce Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 20:24:40 +0700 Subject: [PATCH 10/15] ci(sv): validate last-log-before-timer invariant for M3 epoch-aware START signature --- .github/workflows/esp32p4-smv-injector.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index 4b3d3b6ed..28d37bd06 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -181,9 +181,9 @@ jobs: start_begin = source.index('if (take_start_request())') start_end = source.index('if (!live_tx_running())', start_begin) start_block = source[start_begin:start_end] - clock_start = start_block.rindex( - 'sample_clock = start_sample_clock(task_handle, active_profile.publisher_rate_hz);' - ) + # M3 passes the ETH/PTP epoch context as additional arguments. + # Assert that the timer is still armed after the final console log. + clock_start = start_block.rindex('sample_clock = start_sample_clock(') assert start_block.rindex('ESP_LOGI') < clock_start assert 'ESP_LOG' not in start_block[clock_start:] assert 'static_assert(std::atomic::is_always_lock_free);' in source From fb4329e73c3b80f7d6806e25538e6ceeb06ffc13 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 21:08:06 +0700 Subject: [PATCH 11/15] fix(ptp-sv): qualify sample time not packet TX epoch; preserve UI PTP state (#235) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(studio): prevent stale PTP role readbacks toggling RUNNING off; coalesce serial log UI signals * fix(studio): own bounded telemetry-log notification timer * fix(studio): restore in-session explicit PTP ON after verified firmware reflash * fix(studio): PTP OFF explicitly clears in-session restore intent * fix(sv): distinguish sample phase from nonzero canonical MAC publish delay in AUTO gate * test(sv): prove 110–180us publish latency may qualify while sample phase remains ≤4us * fix(studio): bound in-session post-flash PTP rearm attempt to 36 seconds * test(studio): protect PTP runtime authority, session-only restart intent and coalesced logs * docs(ptp-sv): align AUTO gate with IEC sample time vs MU publish delay, and Studio PTP state ownership --- apps/arstack_studio/qml/Main.qml | 58 ++++++++++++++++++- apps/arstack_studio/qml/StudioTimingTray.qml | 2 +- apps/arstack_studio/src/DeviceController.cpp | 30 ++++------ apps/arstack_studio/src/DeviceController.hpp | 1 + .../tests/check_operator_workspace.py | 16 ++++- docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md | 47 +++++++++++++++ .../time_sync/sv_tx_egress_evidence.hpp | 19 ++++-- tests/test_sv_tx_egress_evidence.cpp | 17 ++++-- 8 files changed, 159 insertions(+), 31 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 3677aa30e..66a0daf89 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -115,6 +115,13 @@ ApplicationWindow { property bool phaseLink: false property bool profileDirty: false property bool pendingApply: true + // Session-only operator intent. Never persist across application launches: + // an explicit PTP ON can be restored after a firmware reflash/reconnect + // without introducing an uncontrolled power-on publisher. + property bool ptpDesiredForSession: false + property string ptpDesiredRole: "SOURCE" + property bool ptpRestoreAfterFirmware: false + property int ptpRestoreChecks: 0 property bool restoringScenario: false property bool offlinePreviewRunning: false property real currentScale: 1000.0 @@ -194,6 +201,44 @@ ApplicationWindow { } } + Connections { + target: workflowBar.session + function onFirmwareUpdateFinished(success) { + // Reset during firmware install stops the board's PTP SOURCE. + // If the operator explicitly enabled it in this live Studio + // session, restore once after the verified board is ready. + // Do not preserve intent after a fresh Studio launch. + if (success && root.ptpDesiredForSession) { + root.ptpRestoreChecks = 0 + root.ptpRestoreAfterFirmware = true + } + } + } + + Timer { + interval: 300 + repeat: true + running: root.ptpRestoreAfterFirmware + onTriggered: { + // Time-bounded one-shot restoration: never retry indefinitely. + root.ptpRestoreChecks += 1 + if (root.ptpRestoreChecks > 120) { + root.ptpRestoreAfterFirmware = false + root.showMessage("PTP restart was not confirmed after reconnect. Use PTP On to retry.", true) + return + } + const session = workflowBar.session + if (!session || session.updatingFirmware || !session.startReady || + !device.deviceVerified || !device.ptpAvailable || + device.ptpStarting) + return + root.ptpRestoreAfterFirmware = false + if (device.ptpRunning || !root.ptpDesiredForSession) return + if (!root.startPtpRole(root.ptpDesiredRole)) + root.showMessage("PTP did not restart after firmware installation. Use PTP On to retry.", true) + } + } + Timer { id: messageTimer interval: 2600 @@ -269,7 +314,18 @@ ApplicationWindow { } function startPtpRole(role) { - return profilePanel.startPtpRole(role) + const accepted = profilePanel.startPtpRole(role) + if (accepted) { + root.ptpDesiredForSession = true + root.ptpDesiredRole = role + } + return accepted + } + + function requestStopPtp() { + root.ptpDesiredForSession = false + root.ptpRestoreAfterFirmware = false + return workflowBar.session && workflowBar.session.requestStopPtp() } function openDiagnostics() { diff --git a/apps/arstack_studio/qml/StudioTimingTray.qml b/apps/arstack_studio/qml/StudioTimingTray.qml index ef6af4eb7..bc79c3a5a 100644 --- a/apps/arstack_studio/qml/StudioTimingTray.qml +++ b/apps/arstack_studio/qml/StudioTimingTray.qml @@ -90,7 +90,7 @@ Rectangle { tray.controller.showMessage("PTP preview only. Connect ESP32-P4 to transmit.", false) } else if (wantsRunning) { if (tray.controller) tray.controller.startPtpRole(tray.roleValues[tray.selectedRoleIndex]) - } else if (tray.session && !tray.session.requestStopPtp() && tray.controller) { + } else if (tray.controller && !tray.controller.requestStopPtp()) { tray.controller.showMessage("PTP stop was not accepted; inspect diagnostics.", true) } } diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 265da7431..b0a216e40 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -86,6 +86,12 @@ const QRegularExpression kSmpSynchExpression{ } // namespace DeviceController::DeviceController(QObject* parent) : QObject(parent) { + // Serial status/log bursts must not force QML to rebind a growing text + // document for every row. Flush at <=10 Hz; parsing/ACKs are immediate. + logUpdateTimer_.setSingleShot(true); + logUpdateTimer_.setInterval(100); + connect(&logUpdateTimer_, &QTimer::timeout, this, + &DeviceController::logTextChanged); ptpStartTimer_.setSingleShot(true); connect(&ptpStartTimer_, &QTimer::timeout, this, [this] { if (!ptpStarting()) return; @@ -1342,6 +1348,7 @@ bool DeviceController::configurePtp(const QVariantMap& profile) { } void DeviceController::clearLog() { + logUpdateTimer_.stop(); if (logText_.isEmpty()) return; logText_.clear(); emit logTextChanged(); @@ -1450,7 +1457,7 @@ void DeviceController::appendLog(const QString& direction, const QString& line) constexpr qsizetype kMaxChars = 60000; constexpr qsizetype kKeepChars = 48000; if (logText_.size() > kMaxChars) logText_ = logText_.right(kKeepChars); - emit logTextChanged(); + if (!logUpdateTimer_.isActive()) logUpdateTimer_.start(); } void DeviceController::processLine(const QString& rawLine) { @@ -1535,23 +1542,10 @@ void DeviceController::processLine(const QString& rawLine) { if (match.hasMatch()) { ptpAvailable_ = true; ptpRole_ = match.captured(1).toUpper(); - ptpRunning_ = match.captured(3) == QStringLiteral("1"); - ptpDiscipline_ = QStringLiteral("UNLOCKED"); - ptpSource_ = QStringLiteral("NONE"); - ptpOffsetNs_ = QStringLiteral("NA"); - ptpPathDelayNs_ = QStringLiteral("NA"); - ptpJitterNs_ = QStringLiteral("NA"); - ptpFrequencyPpb_ = QStringLiteral("0"); - ptpGlobalTraceable_ = false; - ptpMeasuredSmpSynch_ = QStringLiteral("NA"); - ptpRxAnnounce_ = QStringLiteral("0"); - ptpRxSync_ = QStringLiteral("0"); - ptpRxFollowUp_ = QStringLiteral("0"); - ptpRxPdelay_ = QStringLiteral("0"); - ptpPdelayRequests_ = QStringLiteral("0"); - ptpAccepted_ = QStringLiteral("0"); - ptpRejected_ = QStringLiteral("0"); - ptpStatus_ = ptpRunning_ ? QStringLiteral("Timing active") : QStringLiteral("Timing stopped"); + // PROFILE SHOW/role ACK can be an earlier queued readback. It must + // never overwrite verified PTP STATUS running state nor clear the + // independent PTP2 discipline/clock observations. Startup still + // validates this role readback in advancePtpStartup(). emit ptpStateChanged(); } diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 80bc1377f..b2a1dc5ef 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -397,6 +397,7 @@ class DeviceController : public QObject { PtpStartStage ptpStartStage_{PtpStartStage::idle}; QTimer ptpStartTimer_; QTimer ptpStopPollTimer_; + QTimer logUpdateTimer_; QString pendingPtpRole_; QString pendingPtpConfig_; bool ptpRuntimeRoleSeen_{false}; diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index fc1d559dd..ecca26e80 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -36,6 +36,18 @@ def main() -> None: require("Changes sent on Start" in main_qml and "root.pendingApply && !workflowBar.injectionRunning" in main_qml, "STOPPED waveform edits must distinguish draft from sent setpoints") + controller = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") + require("logUpdateTimer_.setInterval(100)" in controller + and "if (!logUpdateTimer_.isActive()) logUpdateTimer_.start();" in controller, + "serial log QML notification must be coalesced to avoid GUI stutter") + role = controller.split("match = kPtpRoleExpression.match(line);")[1].split( + "match = kPtpMeasuredStateExpression.match(line);")[0] + require("ptpRunning_ =" not in role and "ptpDiscipline_ =" not in role, + "stale PROFILE PTPROLE must not override verified PTP runtime") + require("ptpRestoreAfterFirmware" in main_qml + and "root.ptpRestoreChecks > 120" in main_qml + and "root.ptpDesiredForSession = false" in main_qml, + "PTP user intent restoration must be bounded, explicit and cancellable") matrix = source("SignalMatrix.qml") menu = source("StudioActionMenu.qml") cmake = (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8") @@ -52,11 +64,11 @@ def main() -> None: require("StudioTimingTray {" in main_qml and "id: frequencyMenu" in main_qml, "main window must expose timing and frequency context controls") require("function startPtpRole(role) { return timingPanel.startRole(role) }" in config - and "return profilePanel.startPtpRole(role)" in main_qml + and "const accepted = profilePanel.startPtpRole(role)" in main_qml and "requestStartPtpConfigured(role, panel.ptpProfile())" in expert, "main PTP must reuse the existing verified Advanced profile") require("checked = Qt.binding(function()" in tray - and "tray.session.requestStopPtp()" in tray + and "tray.controller.requestStopPtp()" in tray and "tray.session.requestSmpSynch([\"AUTO\", \"0\", \"1\", \"2\"][requested])" in tray and "if (!tray.boardOnline)" in tray, "main PTP/smpSynch must restore device-owned state after user intent") diff --git a/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md b/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md index fdc4ca65b..1cb90b747 100644 --- a/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md +++ b/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md @@ -70,3 +70,50 @@ phase accuracy or guarantee zero relay sync alarms. External specifications: - https://standards.ieee.org/ieee/61850-9-3/6167/ - https://cache.industry.siemens.com/dl/files/460/109742460/att_1135746/v1/SIP5_Processbus_V09.50_Manual_C054-D_en.pdf?download=true + +## M3C.6 — Correct the AUTO local-sync measurement boundary (2026-10-17) + +**Root-cause correction:** A previous M3C.5 gate incorrectly demanded +the actual Ethernet MAC SV **transmission** timestamp be within +/-4 us +of the integer PTP second to allow local `smpSynch=1`. That is not the +IEC 61850 sampling synchronization definition: `smpCnt=0` encodes the +**sampling instant** of the beginning of the PTP second; frame assembly +and transmission follow with positive, variable publisher delay. SEL, +*Performance of IEC 61850 Sampled Values Relays for a Real-World Fault*, +explicitly separates sampled instant from MU processing delay: +https://selinc.com/api/download/137357/?lang=en . +IEC 61850-9-2:2011/AMD1:2020 makes `smpSynch` the publisher's +synchronization-to-device declaration, not a packet-egress coincidence. + +The authoritative `SvPtpPhaseAuthority` second-bound GPTimer sample +observer remains strict: its observed sample phase **plus clock-read +uncertainty** must be <=4 us, with continuous epoch tracking and the +same PTP clock/source lease. Hardware canonical TX descriptor +timestamps now independently require a **plausible publish delay** +from -4 us to +250 us relative to that boundary (one nominal 4000 fps +slot, allowing the bounded ±4 us sample phase). This is *not* a +universal normative network-delay bound; 250 us is this 4kHz +publisher's bounded application admission policy. TX evidence must +still be fresh, uninterrupted, same session and SOURCE generation, +with successful canonical transmissions and no missed slots. + +`smpSynch=AUTO` remains 0 until both independent sample-phase and +publication-evidence gates pass. A manual 1 only tests subscriber +response to the flag; it does not establish clock lock. On failure +AUTO returns 0 and SOURCE never claims global 2. Actual target +latency/timing must be measured on the ESP32-P4 before release. + +The Studio side also treats `PTP STATUS` as authoritative for +RUNNING/STOPPED: a delayed `PROFILE PTPROLE` line can update the role +but must not flip the PTP switch or erase PTP2 discipline telemetry. +The operator's explicit in-session PTP ON can be restored **once after +a successful firmware reflash**, only after the verified device is +ready; OFF cancels intent and no intent persists across Studio restart. +Serial log notifications to QML are coalesced at <=10Hz to avoid +repainting large log documents on every received line. + +Reference: Siemens SIPROTEC 5 Process Bus Manual V09.90, client +`Accepted SmpSynch=1` accepts correctly locally synchronized SV when +the relay and MU share a known reference. This is a **relay setting**, +not a vendor-specific SV wire variant. +https://support.industry.siemens.com/cs/attachments/109742460/SIP5_Processbus_V09.90_Manual_C054-H_en.pdf diff --git a/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp index fc1525e0a..56a908fd1 100644 --- a/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp +++ b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp @@ -186,7 +186,14 @@ class SvTxEgressEvidence final { // uncertainty. Keep this gate independent of ESP-IDF/Qt for fault regression. class SvLocalSyncEvidenceGate final { public: - static constexpr std::int64_t kMaxEgressPhaseNs = 4000LL; + // MAC TX occurs AFTER the sampling instant: message serialization, + // descriptor submission and Ethernet queuing take nonzero time. + // SV SmpCnt=0 is aligned by the software/hardware SAMPLE observer, + // not by demanding that an outgoing Ethernet frame hit that instant. + // Bound the measured canonical TX delay to one nominal 4kHz slot. + // A small negative tolerance accommodates sub-4us sample-phase error. + static constexpr std::int64_t kMinCanonicalTxOffsetNs = -4000LL; + static constexpr std::int64_t kMaxCanonicalTxOffsetNs = 250000LL; static constexpr std::uint32_t kMaxSoftwarePhaseBoundUs = 4U; static constexpr std::uint32_t kEvidenceStride = 16U; static constexpr std::uint32_t kMaxEvidenceAgeEpochs = 17U; @@ -201,8 +208,11 @@ class SvLocalSyncEvidenceGate final { } // Call only for the actual canonical SV descriptor timestamp. - // The lower-level tracker already verifies that consecutive timestamps - // belong to adjacent 16-second PTP epochs and the same source/session. + // The phase here is the packet's TX offset from the nearest whole PTP + // second, i.e. a bound on *publication latency*, NOT sampling phase. + // A separate GPTimer/EMAC observer qualifies the sampling instant. + // The lower-level tracker verifies consecutive 16-second PTP epochs + // and the same source/session. constexpr void observe(const SvTxEgressObservation& o, const SvTxEvidenceResult result, const std::int32_t phase_ns) noexcept { @@ -210,7 +220,8 @@ class SvLocalSyncEvidenceGate final { if (result != SvTxEvidenceResult::measured || !o.canonical_tx_success || !o.no_missed_slots || !o.same_sample_epoch || - phase > kMaxEgressPhaseNs || phase < -kMaxEgressPhaseNs) { + phase > kMaxCanonicalTxOffsetNs || + phase < kMinCanonicalTxOffsetNs) { reset(); return; } diff --git a/tests/test_sv_tx_egress_evidence.cpp b/tests/test_sv_tx_egress_evidence.cpp index 0e6c72248..5fc4234ab 100644 --- a/tests/test_sv_tx_egress_evidence.cpp +++ b/tests/test_sv_tx_egress_evidence.cpp @@ -37,10 +37,13 @@ void check_auto_local_sync_gate_requires_both_clocks_and_fresh_mac() { REQUIRE(!gate.qualified(7U, 0U, 3U, 32U, true, 0U)); gate.observe(o, SvTxEvidenceResult::baseline_established, 2000); REQUIRE(!gate.qualified(7U, 0U, 3U, 32U, true, 0U)); - gate.observe(o, SvTxEvidenceResult::measured, 2000); + // Hardware packet TX normally follows the top-of-second sampling + // instant by processing/queue delay. 110 us is valid publication delay, + // and MUST NOT be mistaken for 110 us sampling phase error. + gate.observe(o, SvTxEvidenceResult::measured, 110000); REQUIRE(!gate.qualified(7U, 0U, 3U, 32U, true, 0U)); o.sample_epoch_generation = 48U; - gate.observe(o, SvTxEvidenceResult::measured, -3000); + gate.observe(o, SvTxEvidenceResult::measured, 175000); REQUIRE(gate.qualified(7U, 0U, 3U, 48U, true, 4U)); REQUIRE(gate.qualified(7U, 0U, 3U, 65U, true, 4U)); REQUIRE(!gate.qualified(7U, 0U, 3U, 66U, true, 4U)); @@ -53,11 +56,15 @@ void check_auto_local_sync_gate_requires_both_clocks_and_fresh_mac() { // A new source, bad SV timestamp, skipped frame or TX failure // immediately revokes the old promotion and requires fresh pairs. o.sample_epoch_generation = 64U; - gate.observe(o, SvTxEvidenceResult::measured, 5000); + gate.observe(o, SvTxEvidenceResult::measured, 300000); REQUIRE(!gate.qualified(7U, 0U, 3U, 64U, true, 0U)); - gate.observe(o, SvTxEvidenceResult::measured, 1000); + // A TX timestamp unexpectedly before the sample epoch is invalid, + // even if a symmetric +/− phase comparison would have accepted it. + gate.observe(o, SvTxEvidenceResult::measured, -9000); + REQUIRE(!gate.qualified(7U, 0U, 3U, 64U, true, 0U)); + gate.observe(o, SvTxEvidenceResult::measured, 110000); o.sample_epoch_generation = 80U; - gate.observe(o, SvTxEvidenceResult::measured, 1000); + gate.observe(o, SvTxEvidenceResult::measured, 180000); REQUIRE(gate.qualified(7U, 0U, 3U, 80U, true, 0U)); gate.observe(o, SvTxEvidenceResult::transmit_failed, 1000); REQUIRE(!gate.qualified(7U, 0U, 3U, 80U, true, 0U)); From 3fb9969c02b7f8816f49989bf6f9603c09427191 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 10:30:40 +0700 Subject: [PATCH 12/15] fix(studio): keep exact SV setpoints and refine operator layout (#242) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(studio): serialize edited phasors directly to live SET and stack voltage/current matrices * style(studio): promote SMV Injector identity to normal desktop header size * style(studio): center ribbon icon and label in both axes * style(studio): consistent hover contrast, alignment and stable checkbox indicator * style(studio): consistent hover contrast, alignment and stable checkbox indicator * style(studio): consistent hover contrast, alignment and stable checkbox indicator * feat(studio): one-shot default PTP SOURCE startup with explicit OFF cancellation * test(studio): lock PTP defaults, vertical 4I4V layout and live table setpoint contract * fix(studio): every START sends exact displayed 4I+4V setpoints, never stale board phases * test(studio): prevent reintroduction of bare START losing displayed phase angles * test(studio): accept readable wrapped START snapshot expression * test(studio): keep Edit-to-Start guarantee independent of formatting * test(studio): enforce vertical voltage/current viewport geometry at 100–175 percent DPI --- apps/arstack_studio/qml/CalmButton.qml | 4 ++ apps/arstack_studio/qml/ConfigurationHub.qml | 4 +- apps/arstack_studio/qml/Main.qml | 51 ++++++++++++++----- apps/arstack_studio/qml/ModernRibbon.qml | 16 +++--- apps/arstack_studio/qml/RibbonAction.qml | 4 +- .../arstack_studio/qml/StudioDarkCheckBox.qml | 4 +- apps/arstack_studio/qml/WorkflowBar.qml | 8 +-- apps/arstack_studio/src/main.cpp | 6 ++- .../tests/check_operator_workspace.py | 27 ++++++++-- 9 files changed, 88 insertions(+), 36 deletions(-) diff --git a/apps/arstack_studio/qml/CalmButton.qml b/apps/arstack_studio/qml/CalmButton.qml index ddddca61c..eb418851a 100644 --- a/apps/arstack_studio/qml/CalmButton.qml +++ b/apps/arstack_studio/qml/CalmButton.qml @@ -22,6 +22,7 @@ Button { Behavior on scale { NumberAnimation { duration: 70; easing.type: Easing.OutCubic } } readonly property color activeFill: !enabled ? "#10161d" : + hovered ? "#edf5ff" : tone === "success" ? (hovered ? "#1e5c43" : "#194d38") : tone === "danger" ? (hovered ? "#523039" : theme.redSoft) : tone === "accent" ? (hovered ? "#1d3c5e" : theme.accentSoft) : @@ -32,11 +33,14 @@ Button { tone === "accent" ? "#315f8d" : (activeFocus ? theme.accent : hovered ? "#3a4a5b" : theme.line) readonly property color activeText: !enabled ? theme.muted2 : + hovered ? "#142334" : tone === "success" ? "#d6f4e6" : tone === "danger" ? "#ffdce0" : tone === "accent" ? "#dcebff" : theme.textSoft contentItem: Row { + // The same item owns both the icon and text; no native Button + // hover glyph can appear independently. spacing: control.iconSource.toString().length > 0 ? 7 : 0 anchors.centerIn: parent Image { diff --git a/apps/arstack_studio/qml/ConfigurationHub.qml b/apps/arstack_studio/qml/ConfigurationHub.qml index fecaa51a3..4e8695895 100644 --- a/apps/arstack_studio/qml/ConfigurationHub.qml +++ b/apps/arstack_studio/qml/ConfigurationHub.qml @@ -34,13 +34,13 @@ Item { font.weight: checked ? Font.DemiBold : Font.Medium contentItem: Label { text: parent.text - color: parent.checked ? hub.theme.text : hub.theme.muted + color: parent.hovered ? "#142334" : parent.checked ? hub.theme.text : hub.theme.muted font: parent.font horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } background: Rectangle { - color: parent.checked ? hub.theme.raised : "transparent" + color: parent.hovered ? "#edf5ff" : parent.checked ? hub.theme.raised : "transparent" radius: 6 border.width: parent.checked ? 1 : 0 border.color: hub.theme.line diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 66a0daf89..72ac77a79 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -52,10 +52,9 @@ ApplicationWindow { function markScenarioDirty() { if (restoringScenario) return saveScenarioTimer.restart() - // While STOPPED, a new edit belongs to the next supervised Start batch. - // Only a genuinely RUNNING board receives incremental live SETs. - if (!workflowBar.injectionRunning) - pendingApply = true + // All edits are a draft until the matching command is queued. + // Never label a visible draft as proven transmitted waveform data. + pendingApply = true } function restoreLocalScenario() { const saved = scenarioSettings.lastScenario @@ -122,6 +121,7 @@ ApplicationWindow { property string ptpDesiredRole: "SOURCE" property bool ptpRestoreAfterFirmware: false property int ptpRestoreChecks: 0 + property bool ptpDefaultStartAttempted: false property bool restoringScenario: false property bool offlinePreviewRunning: false property real currentScale: 1000.0 @@ -215,6 +215,23 @@ ApplicationWindow { } } + // Operator-friendly default: start SOURCE with the verified profile + // once the connected board is READY, never in a blind reconnect loop. + // A deliberate OFF cancels this default for the rest of the session. + Timer { + interval: 500 + repeat: true + running: !root.ptpDefaultStartAttempted + onTriggered: { + const session = workflowBar.session + if (!session || !device.deviceVerified || !session.startReady || + session.updatingFirmware || device.ptpStarting) return + root.ptpDefaultStartAttempted = true + if (!device.ptpRunning && !root.startPtpRole("SOURCE")) + root.showMessage("PTP could not start automatically; use PTP On to retry.", true) + } + } + Timer { interval: 300 repeat: true @@ -323,6 +340,7 @@ ApplicationWindow { } function requestStopPtp() { + root.ptpDefaultStartAttempted = true root.ptpDesiredForSession = false root.ptpRestoreAfterFirmware = false return workflowBar.session && workflowBar.session.requestStopPtp() @@ -467,9 +485,12 @@ ApplicationWindow { selectSignal(group, row) markScenarioDirty() - if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) { - if (phaseLink && row < 3) sendLinkedGroup(group) - else sendSignal(group, row) + if (workflowBar.injectionRunning && workflowBar.session && + workflowBar.session.liveControlReady) { + const queued = phaseLink && row < 3 + ? sendLinkedGroup(group) : sendSignal(group, row) + if (!queued) showMessage("Signal update not queued; retry or Stop/Start.", true) + else pendingApply = false } return true } @@ -487,6 +508,8 @@ ApplicationWindow { return ok } function startScenarioSnapshot() { + // START always serializes the same ListModels that draw the table, + // including all eight current phase angles and enabled flags. var draft = scenarioSnapshot() draft.currentScale = currentScale draft.voltageScale = voltageScale @@ -698,10 +721,10 @@ ApplicationWindow { } Rectangle { width: 1; height: 12; color: studioTheme.lineSoft } Label { - text: root.pendingApply && !workflowBar.injectionRunning - ? "4I + 4V · Changes sent on Start" + text: root.pendingApply + ? "4I + 4V · Setpoints pending · Start to apply" : "4I + 4V · 4000 samples/s" - color: root.pendingApply && !workflowBar.injectionRunning + color: root.pendingApply ? studioTheme.amber : studioTheme.muted font.family: root.uiFont font.pixelSize: 9 @@ -1049,11 +1072,11 @@ ApplicationWindow { } } - RowLayout { + ColumnLayout { Layout.fillWidth: true - Layout.preferredHeight: root.compactLayout ? 214 : 224 - Layout.maximumHeight: root.compactLayout ? 214 : 224 - spacing: 9 + Layout.preferredHeight: root.compactLayout ? 352 : 384 + Layout.maximumHeight: root.compactLayout ? 352 : 384 + spacing: 7 SignalMatrix { id: voltageMatrix enabled: !workflowBar.startQueued diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index aa995b10d..6776d73df 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -47,21 +47,21 @@ Rectangle { spacing: 6 RowLayout { - Layout.preferredWidth: bar.compact ? 105 : 124 + Layout.preferredWidth: bar.compact ? 152 : 182 Layout.alignment: Qt.AlignVCenter spacing: 8 Rectangle { - width: 28 - height: 28 - radius: 7 + width: 38 + height: 38 + radius: 9 color: "#0d151d" border.width: 1 border.color: bar.theme.lineSoft Image { anchors.centerIn: parent - width: 15 - height: 15 + width: 25 + height: 25 source: Qt.resolvedUrl("../assets/material/smv_injector.svg") sourceSize.width: 30 sourceSize.height: 30 @@ -73,8 +73,8 @@ Rectangle { text: "SMV Injector" color: bar.theme.text font.family: bar.uiFont - font.pixelSize: 11 - font.weight: Font.DemiBold + font.pixelSize: 15 + font.weight: Font.Bold elide: Text.ElideRight verticalAlignment: Text.AlignVCenter } diff --git a/apps/arstack_studio/qml/RibbonAction.qml b/apps/arstack_studio/qml/RibbonAction.qml index a882bb495..ae84160c4 100644 --- a/apps/arstack_studio/qml/RibbonAction.qml +++ b/apps/arstack_studio/qml/RibbonAction.qml @@ -40,6 +40,7 @@ Button { : tone === "accent" || checked ? "#e1f1ff" : theme.textSoft contentItem: ColumnLayout { spacing: 3 + anchors.centerIn: parent Image { visible: control.iconSource.toString().length > 0 source: control.hovered && control.enabled ? control.hoverPath : control.iconSource @@ -56,7 +57,8 @@ Button { text: control.text color: control.textColor font: control.font - Layout.alignment: Qt.AlignHCenter + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + Layout.fillWidth: true horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter elide: Text.ElideRight diff --git a/apps/arstack_studio/qml/StudioDarkCheckBox.qml b/apps/arstack_studio/qml/StudioDarkCheckBox.qml index eedfce586..fd2794a78 100644 --- a/apps/arstack_studio/qml/StudioDarkCheckBox.qml +++ b/apps/arstack_studio/qml/StudioDarkCheckBox.qml @@ -9,6 +9,7 @@ CheckBox { property string uiFont: "Inter" implicitHeight: 30 spacing: 9 + hoverEnabled: true font.family: uiFont font.pixelSize: 11 indicator: Rectangle { @@ -19,7 +20,7 @@ CheckBox { radius: 5 color: control.checked ? control.theme.accent : control.theme.surface2 border.width: 1 - border.color: control.checked ? control.theme.accent + border.color: control.checked || control.hovered ? control.theme.accent : control.activeFocus ? control.theme.textSoft : control.theme.line Text { anchors.centerIn: parent @@ -31,6 +32,7 @@ CheckBox { } } contentItem: Text { + horizontalAlignment: Text.AlignLeft leftPadding: control.indicator.width + control.spacing text: control.text font: control.font diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index e49593114..bbc858ac4 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -281,9 +281,11 @@ SurfacePanel { controller.showMessage(ribbon.startReason(), false) return } - const accepted = controller.pendingApply - ? smartSession.requestStartWithScenario(controller.startScenarioSnapshot()) - : smartSession.requestStart() + // A START always transmits the current 4I+4V table snapshot. + // A previous run may have cleared pendingApply; a bare START could + // then repeat old board angles while Studio displays edited values. + const accepted = smartSession.requestStartWithScenario( + controller.startScenarioSnapshot()) if (!accepted) { controller.pendingApply = true controller.showMessage("Start rejected. Check device status.", true) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 13518af84..1d2c0b492 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -82,8 +82,10 @@ struct UiBounds { return false; } } - if (!(voltage.area.right() <= current.area.left() + tolerance - && tray.area.top() >= voltage.area.bottom() - tolerance + // Operator layout: voltage above current. Validate the new actual + // vertical hierarchy, not the obsolete side-by-side geometry. + if (!(voltage.area.bottom() <= current.area.top() + tolerance + && tray.area.top() >= current.area.bottom() - tolerance && quality.area.top() >= tray.area.bottom() - tolerance && quality.area.bottom() <= surface.area.bottom() + tolerance)) { qCritical().noquote() << "Studio UI panel order/overlap violation" << size diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index ecca26e80..afea41801 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -33,9 +33,20 @@ def main() -> None: require("Lab simulation controls" not in config and "Signals, firmware, PTP and device settings" in config, "Advanced must not imply this is only a simulated lab tool") - require("Changes sent on Start" in main_qml - and "root.pendingApply && !workflowBar.injectionRunning" in main_qml, - "STOPPED waveform edits must distinguish draft from sent setpoints") + require("Setpoints pending · Start to apply" in main_qml + and "root.pendingApply" in main_qml + and "pendingApply = true" in main_qml, + "table edits must expose unsent setpoints without claiming wire readback") + require("Layout.preferredHeight: root.compactLayout ? 352 : 384" in main_qml + and "ColumnLayout {" in main_qml, + "voltage and current must stack vertically to use the workspace") + require('property bool ptpDefaultStartAttempted: false' in main_qml + and 'root.ptpDefaultStartAttempted = true' in main_qml + and '!root.startPtpRole("SOURCE")' in main_qml, + "default PTP start must be one-shot and deliberate OFF must cancel it") + require("phaseLink && row < 3" in main_qml + and "sendLinkedGroup(group) : sendSignal(group, row)" in main_qml, + "edited 3-phase angles must be transmitted from the same table model") controller = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") require("logUpdateTimer_.setInterval(100)" in controller and "if (!logUpdateTimer_.isActive()) logUpdateTimer_.start();" in controller, @@ -81,6 +92,10 @@ def main() -> None: require("1.75" in (STUDIO.parent.parent / ".github/workflows/arstack-studio-windows-lifecycle.yml").read_text(encoding="utf-8"), "reuse Windows executable for 100%, 150% and 175% DPI acceptance") workflow = source("WorkflowBar.qml") + require('const accepted = smartSession.requestStartWithScenario(' in workflow + and 'controller.startScenarioSnapshot()' in workflow + and 'smartSession.requestStart()' not in workflow, + "every Start must send the visible 4I+4V table, never reuse stale device angles") require("Qt.callLater(installDialog.open)" not in workflow and "Qt.callLater(updateDialog.open)" not in workflow and "if (!success) Qt.callLater(resultDialog.open)" in workflow @@ -116,7 +131,8 @@ def main() -> None: and 'enabled: !workflowBar.startQueued; onActivated: root.zeroAll()' in main_qml, "scenario editors and shortcuts must be frozen during a pending START snapshot") require("controller.pendingApply = true" in workflow - and "requestStartWithScenario(controller.startScenarioSnapshot())" in workflow + and "smartSession.requestStartWithScenario(" in workflow + and "controller.startScenarioSnapshot())" in workflow and "controller.applyAllSignals()" not in workflow and "startWatchdog" in workflow and "onRunningChanged()" in workflow, "reconnect must not auto-transmit, but one-click Start must stage a bounded scenario") @@ -145,7 +161,8 @@ def main() -> None: and 'toolTipText: "Advanced"' not in ribbon and 'ToolTip.text: "smpSynch mode"' not in timing_ui, "captioned operator controls must not duplicate labels as tooltips") - require('requestStartWithScenario(controller.startScenarioSnapshot())' in workflow + require('smartSession.requestStartWithScenario(' in workflow + and 'controller.startScenarioSnapshot())' in workflow and "Apply to board" not in main_qml, "Edit-to-Start must not regress to a manual Apply-to-board action") require("assets/material/smv_injector.svg" in cmake_icons From 8999bac380476d50e2925481ad62d4fdb9ba75b5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 11:34:46 +0700 Subject: [PATCH 13/15] fix(studio): flush phasors before START, responsive stacked matrices and legible controls (#243) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(studio): synchronously commit focused and debounced phasor cells before START snapshot * fix(studio): flush displayed phase before START and expand stacked 4I/4V matrices * fix(studio): reject START if an active table editor has invalid or unsent phase * fix(studio): readable brand, centered ribbon/buttons and stable checkbox hover geometry * fix(studio): readable brand, centered ribbon/buttons and stable checkbox hover geometry * fix(studio): readable brand, centered ribbon/buttons and stable checkbox hover geometry * fix(studio): readable brand, centered ribbon/buttons and stable checkbox hover geometry * fix(ribbon): center icon+caption vertically with symmetric stretch spacers * test(studio): protect phase start snapshot, vertical matrix, branding and consistent hover * test(studio): validate flexible vertical matrix instead of obsolete fixed height * test(studio): regress actual IA −30°/IB −150°/IC +90° wire SET encoding * test(studio): require validated table snapshot before START admission * test(studio): enforce one-click validated START without obsolete inline expression * fix(qml): remove read-only Row implicit size assignments; preserve centered content * fix(qml): do not assign generated Text implicitHeight in checkbox content * fix(studio): keep stacked editors elastic and within 960x620 Windows viewport * test(studio): require compressible editor min-height at 960x620 --- apps/arstack_studio/qml/CalmButton.qml | 3 +- apps/arstack_studio/qml/Main.qml | 18 ++++++++-- apps/arstack_studio/qml/ModernRibbon.qml | 20 ++++++----- apps/arstack_studio/qml/RibbonAction.qml | 8 +++-- apps/arstack_studio/qml/SignalMatrix.qml | 31 +++++++++++++++++ .../arstack_studio/qml/StudioDarkCheckBox.qml | 11 +++++-- apps/arstack_studio/qml/WorkflowBar.qml | 7 ++-- .../src/DeterministicSessionHarness.cpp | 10 ++++++ .../tests/check_operator_workspace.py | 33 ++++++++++++++++--- 9 files changed, 116 insertions(+), 25 deletions(-) diff --git a/apps/arstack_studio/qml/CalmButton.qml b/apps/arstack_studio/qml/CalmButton.qml index eb418851a..882788bfe 100644 --- a/apps/arstack_studio/qml/CalmButton.qml +++ b/apps/arstack_studio/qml/CalmButton.qml @@ -39,8 +39,7 @@ Button { tone === "accent" ? "#dcebff" : theme.textSoft contentItem: Row { - // The same item owns both the icon and text; no native Button - // hover glyph can appear independently. + // Both caption and icon belong to the same centered Control item. spacing: control.iconSource.toString().length > 0 ? 7 : 0 anchors.centerIn: parent Image { diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 72ac77a79..4ac8d2779 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -508,6 +508,12 @@ ApplicationWindow { return ok } function startScenarioSnapshot() { + // The table editors debounce keyboard edits for live preview. Flush + // them synchronously before creating the ordered board START payload. + if (!currentMatrix.flushPendingEdits() || !voltageMatrix.flushPendingEdits()) { + showMessage("Invalid signal input: correct highlighted field before Start.", true) + return null + } // START always serializes the same ListModels that draw the table, // including all eight current phase angles and enabled flags. var draft = scenarioSnapshot() @@ -1074,9 +1080,15 @@ ApplicationWindow { ColumnLayout { Layout.fillWidth: true - Layout.preferredHeight: root.compactLayout ? 352 : 384 - Layout.maximumHeight: root.compactLayout ? 352 : 384 - spacing: 7 + // Use all available vertical workspace rather than + // a fixed 384px island with empty space below. + Layout.fillHeight: true + Layout.preferredHeight: root.compactLayout ? 350 : 380 + // Allow 960x620 minimum viewport to shrink without + // clipping timing and quality rows; on larger + // viewports this column expands into spare space. + Layout.minimumHeight: root.compactLayout ? 180 : 210 + spacing: 8 SignalMatrix { id: voltageMatrix enabled: !workflowBar.startQueued diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index 6776d73df..27fdd8139 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -47,24 +47,26 @@ Rectangle { spacing: 6 RowLayout { - Layout.preferredWidth: bar.compact ? 152 : 182 + Layout.preferredWidth: bar.compact ? 190 : 224 Layout.alignment: Qt.AlignVCenter spacing: 8 Rectangle { - width: 38 - height: 38 - radius: 9 + Layout.preferredWidth: 48 + Layout.preferredHeight: 48 + width: 48 + height: 48 + radius: 10 color: "#0d151d" border.width: 1 border.color: bar.theme.lineSoft Image { anchors.centerIn: parent - width: 25 - height: 25 + width: 34 + height: 34 source: Qt.resolvedUrl("../assets/material/smv_injector.svg") - sourceSize.width: 30 - sourceSize.height: 30 + sourceSize.width: 68 + sourceSize.height: 68 } } @@ -73,7 +75,7 @@ Rectangle { text: "SMV Injector" color: bar.theme.text font.family: bar.uiFont - font.pixelSize: 15 + font.pixelSize: bar.compact ? 17 : 19 font.weight: Font.Bold elide: Text.ElideRight verticalAlignment: Text.AlignVCenter diff --git a/apps/arstack_studio/qml/RibbonAction.qml b/apps/arstack_studio/qml/RibbonAction.qml index ae84160c4..8e5bf1ff3 100644 --- a/apps/arstack_studio/qml/RibbonAction.qml +++ b/apps/arstack_studio/qml/RibbonAction.qml @@ -39,8 +39,10 @@ Button { : tone === "danger" ? "#ffe3e6" : tone === "accent" || checked ? "#e1f1ff" : theme.textSoft contentItem: ColumnLayout { - spacing: 3 - anchors.centerIn: parent + spacing: 4 + // Controls own the content rectangle; centering is declarative + // on the layout itself, rather than anchoring Qt Controls content. + Item { Layout.fillHeight: true } Image { visible: control.iconSource.toString().length > 0 source: control.hovered && control.enabled ? control.hoverPath : control.iconSource @@ -59,10 +61,12 @@ Button { font: control.font Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter Layout.fillWidth: true + Layout.preferredHeight: 18 horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter elide: Text.ElideRight } + Item { Layout.fillHeight: true } } background: Rectangle { radius: 9 diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index 1c84e2b43..101e9c2d3 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -27,6 +27,17 @@ Rectangle { return [theme.phaseA, theme.phaseB, theme.phaseC, theme.phaseN][index] } + // Commit any last keystroke synchronously before constructing an ordered + // START scenario. The 90ms edit debounce must never make the board + // receive old angles while the table already displays new ones. + function flushPendingEdits() { + for (var i = 0; i < rowRepeater.count; ++i) { + const row = rowRepeater.itemAt(i) + if (row && !row.flushPendingEdits()) return false + } + return true + } + function focusCell(row, column) { var rowItem = rowRepeater.itemAt(row) if (!rowItem) @@ -124,6 +135,26 @@ Rectangle { property real pendingMagnitude: mag property real pendingPhase: angle + function flushPendingEdits() { + if (magnitudeApplyTimer.running || magnitudeField.activeFocus) { + const magValue = matrix.controller.parseOperatorNumber(magnitudeField.text) + if (!matrix.controller.validMagnitude(matrix.groupIndex, magValue)) + return false + magnitudeApplyTimer.stop() + if (!matrix.controller.editSignal(matrix.groupIndex, rowIndex, "magnitude", magValue)) + return false + } + if (phaseApplyTimer.running || phaseField.activeFocus) { + const phaseValue = matrix.controller.parseOperatorNumber(phaseField.text) + if (!matrix.controller.validPhase(phaseValue)) + return false + phaseApplyTimer.stop() + if (!matrix.controller.editSignal(matrix.groupIndex, rowIndex, "phase", phaseValue)) + return false + } + return true + } + // Align with the field edge, not the left side of the table. // Clamp inside the owning matrix and open below the editor; // keyboard Menu/Shift+F10 uses exactly the same placement. diff --git a/apps/arstack_studio/qml/StudioDarkCheckBox.qml b/apps/arstack_studio/qml/StudioDarkCheckBox.qml index fd2794a78..a50529053 100644 --- a/apps/arstack_studio/qml/StudioDarkCheckBox.qml +++ b/apps/arstack_studio/qml/StudioDarkCheckBox.qml @@ -8,13 +8,18 @@ CheckBox { property var theme property string uiFont: "Inter" implicitHeight: 30 + implicitWidth: Math.max(28, contentItem.implicitWidth + 30) + leftPadding: 1 + rightPadding: 2 spacing: 9 hoverEnabled: true font.family: uiFont font.pixelSize: 11 indicator: Rectangle { - implicitWidth: 18 - implicitHeight: 18 + implicitWidth: 20 + implicitHeight: 20 + width: 20 + height: 20 x: control.leftPadding y: (control.height - height) / 2 radius: 5 @@ -27,7 +32,7 @@ CheckBox { visible: control.checked text: "\u2713" color: control.theme.bg - font.pixelSize: 13 + font.pixelSize: 14 font.weight: Font.Bold } } diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index bbc858ac4..943258148 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -284,8 +284,11 @@ SurfacePanel { // A START always transmits the current 4I+4V table snapshot. // A previous run may have cleared pendingApply; a bare START could // then repeat old board angles while Studio displays edited values. - const accepted = smartSession.requestStartWithScenario( - controller.startScenarioSnapshot()) + // Validate/flush the visible 4I+4V table before entering the + // exclusive device START transaction; no stale debounced angle. + const scenario = controller.startScenarioSnapshot() + if (!scenario) return + const accepted = smartSession.requestStartWithScenario(scenario) if (!accepted) { controller.pendingApply = true controller.showMessage("Start rejected. Check device status.", true) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index bd5fe2233..da467e2b9 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -644,6 +644,13 @@ class DeterministicSessionHarness final { QStringLiteral("Ib"), 1.0, -120.0, 0U, 1000.0, 100.0, 50.0); const QString ic = DeviceController::formatSignalCommand( QStringLiteral("Ic"), 1.0, 120.0, 0U, 1000.0, 100.0, 50.0); + // Operator table −30/−150/+90 must NEVER be serialized as 0/−120/+120. + const QString iaOperator = DeviceController::formatSignalCommand( + QStringLiteral("Ia"), 1.0, -30.0, 0U, 1000.0, 100.0, 50.0); + const QString ibOperator = DeviceController::formatSignalCommand( + QStringLiteral("Ib"), 1.0, -150.0, 0U, 1000.0, 100.0, 50.0); + const QString icOperator = DeviceController::formatSignalCommand( + QStringLiteral("Ic"), 1.0, 90.0, 0U, 1000.0, 100.0, 50.0); const QString badId = DeviceController::formatSignalCommand( QStringLiteral("IX"), 1.0, 0.0, 0U, 1000.0, 100.0, 50.0); const QString overflow = DeviceController::formatSignalCommand( @@ -653,6 +660,9 @@ class DeterministicSessionHarness final { return ia == QStringLiteral("SET IA 1000 0 0") && ib == QStringLiteral("SET IB 1000 -120000 0") && ic == QStringLiteral("SET IC 1000 120000 0") && + iaOperator == QStringLiteral("SET IA 1000 -30000 0") && + ibOperator == QStringLiteral("SET IB 1000 -150000 0") && + icOperator == QStringLiteral("SET IC 1000 90000 0") && badId.isEmpty() && overflow.isEmpty() && dcNegative == QStringLiteral("SET IA -1000 0 0"); } diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index afea41801..184b54d46 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -37,9 +37,10 @@ def main() -> None: and "root.pendingApply" in main_qml and "pendingApply = true" in main_qml, "table edits must expose unsent setpoints without claiming wire readback") - require("Layout.preferredHeight: root.compactLayout ? 352 : 384" in main_qml + require("Layout.minimumHeight: root.compactLayout ? 180 : 210" in main_qml + and "Layout.fillHeight: true" in main_qml and "ColumnLayout {" in main_qml, - "voltage and current must stack vertically to use the workspace") + "voltage and current must stack vertically and fill the workspace") require('property bool ptpDefaultStartAttempted: false' in main_qml and 'root.ptpDefaultStartAttempted = true' in main_qml and '!root.startPtpRole("SOURCE")' in main_qml, @@ -60,6 +61,28 @@ def main() -> None: and "root.ptpDesiredForSession = false" in main_qml, "PTP user intent restoration must be bounded, explicit and cancellable") matrix = source("SignalMatrix.qml") + require("function flushPendingEdits()" in matrix + and "magnitudeApplyTimer.stop()" in matrix + and "phaseApplyTimer.stop()" in matrix + and "currentMatrix.flushPendingEdits()" in main_qml + and "voltageMatrix.flushPendingEdits()" in main_qml + and "if (!scenario) return" in source("WorkflowBar.qml"), + "START must commit last visible I/V angle before ordered scenario serialization") + require("Layout.fillHeight: true" in main_qml + and "Layout.maximumHeight: root.compactLayout ? 352 : 384" not in main_qml + and main_qml.index("id: voltageMatrix") < main_qml.index("id: currentMatrix"), + "Voltage and Current need a vertical, flexible-height editor workspace") + ribbon_qml = source("ModernRibbon.qml") + require("Layout.preferredWidth: bar.compact ? 190 : 224" in ribbon_qml + and "font.pixelSize: bar.compact ? 17 : 19" in ribbon_qml, + "SMV Injector logo/caption must be instrument-sized, not micro branding") + checkbox_qml = source("StudioDarkCheckBox.qml") + require("width: 20" in checkbox_qml and "height: 20" in checkbox_qml, + "checkbox geometry must remain unchanged by hover") + require("hovered ? \"#edf5ff\"" in source("CalmButton.qml") + and "hovered ? \"#142334\"" in source("CalmButton.qml"), + "light hover background must always pair with dark caption") + menu = source("StudioActionMenu.qml") cmake = (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8") @@ -132,7 +155,8 @@ def main() -> None: "scenario editors and shortcuts must be frozen during a pending START snapshot") require("controller.pendingApply = true" in workflow and "smartSession.requestStartWithScenario(" in workflow - and "controller.startScenarioSnapshot())" in workflow + and "const scenario = controller.startScenarioSnapshot()" in workflow + and "if (!scenario) return" in workflow and "controller.applyAllSignals()" not in workflow and "startWatchdog" in workflow and "onRunningChanged()" in workflow, "reconnect must not auto-transmit, but one-click Start must stage a bounded scenario") @@ -162,7 +186,8 @@ def main() -> None: and 'ToolTip.text: "smpSynch mode"' not in timing_ui, "captioned operator controls must not duplicate labels as tooltips") require('smartSession.requestStartWithScenario(' in workflow - and 'controller.startScenarioSnapshot())' in workflow + and 'const scenario = controller.startScenarioSnapshot()' in workflow + and 'if (!scenario) return' in workflow and "Apply to board" not in main_qml, "Edit-to-Start must not regress to a manual Apply-to-board action") require("assets/material/smv_injector.svg" in cmake_icons From aa1a1670b84a358078439901c3fe375a44d5c531 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 12:47:55 +0700 Subject: [PATCH 14/15] fix(studio): readable 4I+4V stacked workspace and locked SV/PTP UI acceptance (#244) * fix(studio): improve readable ribbon, consistent hover controls and vertically filled injection layout * fix(studio): improve readable ribbon, consistent hover controls and vertically filled injection layout * fix(studio): improve readable ribbon, consistent hover controls and vertically filled injection layout * fix(studio): improve readable ribbon, consistent hover controls and vertically filled injection layout * fix(studio): improve readable ribbon, consistent hover controls and vertically filled injection layout * fix(studio): avoid conflicting anchors in ribbon Controls content layout * test(studio): lock snapshot START, full 4I+4V signed phases and readable UI components * fix(studio): dark caption on white hover for Advanced icon/text tool buttons * test(studio): keep readable Advanced tool hovers and single-size checkbox visual * test(studio): pin verified SV/PTP phase-evidence profile across future GUI builds * fix(studio): Advanced dropdown hover uses white surface and dark readable option text * test(studio): prevent white-on-white Advanced dropdown hover regressions * docs: freeze 120s SV/PTP local-lock wire checkpoint and table-to-SV fidelity acceptance * fix(studio): pair white Advanced button hover with dark icon artwork too * test(studio): preserve dark icon/caption on light Advanced hover * fix(studio): distribute vertical matrix height among four editable channels instead of blank spacer * test(studio): prevent blank-bottom Voltage/Current editor layout regression --- apps/arstack_studio/qml/CalmButton.qml | 8 ++- apps/arstack_studio/qml/DarkToolButton.qml | 4 +- apps/arstack_studio/qml/Main.qml | 9 +-- apps/arstack_studio/qml/ModernRibbon.qml | 16 ++--- apps/arstack_studio/qml/RibbonAction.qml | 4 +- apps/arstack_studio/qml/SignalMatrix.qml | 6 +- .../arstack_studio/qml/StudioDarkCheckBox.qml | 9 ++- .../arstack_studio/qml/StudioDarkComboBox.qml | 5 +- .../tests/check_operator_workspace.py | 58 +++++++++++++++++-- docs/ARSTACK_SV_PTP_LOCK_BASELINE_20261018.md | 20 +++++++ 10 files changed, 109 insertions(+), 30 deletions(-) create mode 100644 docs/ARSTACK_SV_PTP_LOCK_BASELINE_20261018.md diff --git a/apps/arstack_studio/qml/CalmButton.qml b/apps/arstack_studio/qml/CalmButton.qml index 882788bfe..924570197 100644 --- a/apps/arstack_studio/qml/CalmButton.qml +++ b/apps/arstack_studio/qml/CalmButton.qml @@ -10,6 +10,7 @@ Button { property url iconSource property real iconSize: 16 property string toolTipText: "" + readonly property string hoverPath: iconSource.toString().replace("/assets/material/", "/assets/material_dark/") implicitHeight: theme ? theme.controlHeight : 36 implicitWidth: Math.max(64, contentItem.implicitWidth + 20) @@ -28,6 +29,7 @@ Button { tone === "accent" ? (hovered ? "#1d3c5e" : theme.accentSoft) : (down ? "#1a2530" : hovered ? theme.raisedHover : "#141c25") readonly property color activeEdge: !enabled ? theme.lineSoft : + hovered ? "#aac9e9" : tone === "success" ? "#347a59" : tone === "danger" ? "#86434b" : tone === "accent" ? "#315f8d" : @@ -39,15 +41,15 @@ Button { tone === "accent" ? "#dcebff" : theme.textSoft contentItem: Row { - // Both caption and icon belong to the same centered Control item. - spacing: control.iconSource.toString().length > 0 ? 7 : 0 + // Center the complete icon+caption pair in both axes in Qt Controls. anchors.centerIn: parent + spacing: control.iconSource.toString().length > 0 ? 7 : 0 Image { visible: control.iconSource.toString().length > 0 width: control.iconSize height: control.iconSize anchors.verticalCenter: parent.verticalCenter - source: control.iconSource + source: control.hovered && control.enabled ? control.hoverPath : control.iconSource sourceSize.width: control.iconSize * 2 sourceSize.height: control.iconSize * 2 opacity: control.enabled ? 0.94 : 0.30 diff --git a/apps/arstack_studio/qml/DarkToolButton.qml b/apps/arstack_studio/qml/DarkToolButton.qml index f21242095..0b00d1357 100644 --- a/apps/arstack_studio/qml/DarkToolButton.qml +++ b/apps/arstack_studio/qml/DarkToolButton.qml @@ -29,7 +29,7 @@ ToolButton { visible: control.iconSource.toString().length === 0 anchors.fill: parent text: control.text - color: control.enabled ? (control.hovered ? "#ffffff" : theme.textSoft) : theme.muted2 + color: control.enabled ? (control.hovered ? "#142334" : theme.textSoft) : theme.muted2 font: control.font horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter @@ -37,7 +37,7 @@ ToolButton { } background: Rectangle { radius: 6 - color: control.down ? theme.accentSoft : (control.hovered ? theme.raisedHover : "transparent") + color: control.down ? "#d6e6f9" : (control.hovered ? "#edf5ff" : "transparent") border.width: control.activeFocus ? 1 : 0 border.color: theme.accent } diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 4ac8d2779..05a29a3c6 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -1080,10 +1080,10 @@ ApplicationWindow { ColumnLayout { Layout.fillWidth: true - // Use all available vertical workspace rather than - // a fixed 384px island with empty space below. + // Stack Voltage and Current vertically; allocate + // the available height between the two matrices. Layout.fillHeight: true - Layout.preferredHeight: root.compactLayout ? 350 : 380 + Layout.preferredHeight: root.compactLayout ? 350 : 450 // Allow 960x620 minimum viewport to shrink without // clipping timing and quality rows; on larger // viewports this column expands into spare space. @@ -1171,7 +1171,8 @@ ApplicationWindow { compact: root.compactLayout } } - Item { Layout.fillHeight: true } + // No trailing filler: vertically stacked matrices + // expand to use the otherwise blank injection space. } } diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index 27fdd8139..bf9a737ab 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -47,23 +47,23 @@ Rectangle { spacing: 6 RowLayout { - Layout.preferredWidth: bar.compact ? 190 : 224 + Layout.preferredWidth: bar.compact ? 220 : 270 Layout.alignment: Qt.AlignVCenter spacing: 8 Rectangle { - Layout.preferredWidth: 48 - Layout.preferredHeight: 48 - width: 48 - height: 48 + Layout.preferredWidth: 56 + Layout.preferredHeight: 56 + width: 56 + height: 56 radius: 10 color: "#0d151d" border.width: 1 border.color: bar.theme.lineSoft Image { anchors.centerIn: parent - width: 34 - height: 34 + width: 44 + height: 44 source: Qt.resolvedUrl("../assets/material/smv_injector.svg") sourceSize.width: 68 sourceSize.height: 68 @@ -75,7 +75,7 @@ Rectangle { text: "SMV Injector" color: bar.theme.text font.family: bar.uiFont - font.pixelSize: bar.compact ? 17 : 19 + font.pixelSize: bar.compact ? 20 : 22 font.weight: Font.Bold elide: Text.ElideRight verticalAlignment: Text.AlignVCenter diff --git a/apps/arstack_studio/qml/RibbonAction.qml b/apps/arstack_studio/qml/RibbonAction.qml index 8e5bf1ff3..2855e1436 100644 --- a/apps/arstack_studio/qml/RibbonAction.qml +++ b/apps/arstack_studio/qml/RibbonAction.qml @@ -40,8 +40,8 @@ Button { : tone === "accent" || checked ? "#e1f1ff" : theme.textSoft contentItem: ColumnLayout { spacing: 4 - // Controls own the content rectangle; centering is declarative - // on the layout itself, rather than anchoring Qt Controls content. + // Qt Quick Controls sizes the content rectangle; equal stretchers + // center the icon and caption vertically without anchor conflicts. Item { Layout.fillHeight: true } Image { visible: control.iconSource.toString().length > 0 diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index 101e9c2d3..ac323221d 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -227,6 +227,10 @@ Rectangle { } Layout.fillWidth: true + // Distribute spare workspace across editable channel rows; + // never leave a large dead zone under four fixed-height rows. + Layout.fillHeight: true + Layout.minimumHeight: 34 Layout.preferredHeight: matrix.compact ? 40 : 42 color: signalRow.selected ? "#101c27" : (magnitudeField.activeFocus || phaseField.activeFocus) ? "#0f1922" @@ -438,6 +442,6 @@ Rectangle { } } - Item { Layout.fillHeight: true } + // Channel delegates stretch into available height. } } diff --git a/apps/arstack_studio/qml/StudioDarkCheckBox.qml b/apps/arstack_studio/qml/StudioDarkCheckBox.qml index a50529053..8fd0d320a 100644 --- a/apps/arstack_studio/qml/StudioDarkCheckBox.qml +++ b/apps/arstack_studio/qml/StudioDarkCheckBox.qml @@ -7,7 +7,10 @@ CheckBox { id: control property var theme property string uiFont: "Inter" - implicitHeight: 30 + implicitHeight: 32 + // Keep one fixed indicator geometry at rest, during hover and focus. + // Native style decorations must not replace this explicit indicator. + background: Item { implicitWidth: 1; implicitHeight: 1 } implicitWidth: Math.max(28, contentItem.implicitWidth + 30) leftPadding: 1 rightPadding: 2 @@ -25,8 +28,8 @@ CheckBox { radius: 5 color: control.checked ? control.theme.accent : control.theme.surface2 border.width: 1 - border.color: control.checked || control.hovered ? control.theme.accent - : control.activeFocus ? control.theme.textSoft : control.theme.line + border.color: control.checked || control.hovered || control.activeFocus + ? control.theme.accent : control.theme.line Text { anchors.centerIn: parent visible: control.checked diff --git a/apps/arstack_studio/qml/StudioDarkComboBox.qml b/apps/arstack_studio/qml/StudioDarkComboBox.qml index 60f7afb00..3e84add7c 100644 --- a/apps/arstack_studio/qml/StudioDarkComboBox.qml +++ b/apps/arstack_studio/qml/StudioDarkComboBox.qml @@ -52,9 +52,10 @@ ComboBox { width: combo.width - 8 implicitHeight: 36 highlighted: combo.highlightedIndex === index + hoverEnabled: true contentItem: Text { text: combo.textAt(choice.index) - color: combo.theme.text + color: choice.highlighted || choice.hovered ? "#142334" : combo.theme.text font: combo.font verticalAlignment: Text.AlignVCenter elide: Text.ElideRight @@ -63,7 +64,7 @@ ComboBox { } background: Rectangle { radius: 5 - color: parent.highlighted ? combo.theme.accentSoft : combo.theme.raised + color: choice.highlighted || choice.hovered ? "#edf5ff" : combo.theme.raised } } diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 184b54d46..11825422f 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -25,6 +25,22 @@ def ordered(data: str, first: str, second: str, reason: str) -> None: def main() -> None: + # Lock the successful 120-second PTP/SV baseline in the operator CI. + # A GUI-only change must not silently restore the old free-running bundle. + project_root = STUDIO.parent.parent + firmware_profile = (project_root / ".github/workflows/esp32p4-smv-injector.yml").read_text(encoding="utf-8") + preview_profile = (project_root / ".github/workflows/arstack-studio-native-preview.yml").read_text(encoding="utf-8") + require("CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y" in firmware_profile + and "CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL=y" in firmware_profile + and "# CONFIG_AR_SMV_BROADCAST_MIRROR is not set" in firmware_profile + and "phase_sync_preview: true" in preview_profile, + "Studio native preview must ship measured PTP-SV AUTO firmware with no diagnostic mirror") + sv_gate = (project_root / "include/ariec61850/time_sync/sv_tx_egress_evidence.hpp").read_text(encoding="utf-8") + sv_tests = (project_root / "tests/test_sv_tx_egress_evidence.cpp").read_text(encoding="utf-8") + require("kMaxSoftwarePhaseBoundUs = 4U" in sv_gate + and "kMaxCanonicalTxOffsetNs = 250000LL" in sv_gate + and "check_auto_local_sync_gate_requires_both_clocks_and_fresh_mac" in sv_tests, + "retain independently bounded PTP sample-phase and nonzero MAC publication latency") main_qml = source("Main.qml") preview = source("SignalPreview.qml") tray = source("StudioTimingTray.qml") @@ -61,6 +77,10 @@ def main() -> None: and "root.ptpDesiredForSession = false" in main_qml, "PTP user intent restoration must be bounded, explicit and cancellable") matrix = source("SignalMatrix.qml") + require("Layout.fillHeight: true" in matrix + and "Layout.minimumHeight: 34" in matrix + and "Item { Layout.fillHeight: true }" not in matrix, + "editable channel rows must consume vertical space without a blank filler") require("function flushPendingEdits()" in matrix and "magnitudeApplyTimer.stop()" in matrix and "phaseApplyTimer.stop()" in matrix @@ -73,15 +93,43 @@ def main() -> None: and main_qml.index("id: voltageMatrix") < main_qml.index("id: currentMatrix"), "Voltage and Current need a vertical, flexible-height editor workspace") ribbon_qml = source("ModernRibbon.qml") - require("Layout.preferredWidth: bar.compact ? 190 : 224" in ribbon_qml - and "font.pixelSize: bar.compact ? 17 : 19" in ribbon_qml, - "SMV Injector logo/caption must be instrument-sized, not micro branding") + require("Layout.preferredWidth: bar.compact ? 220 : 270" in ribbon_qml + and "font.pixelSize: bar.compact ? 20 : 22" in ribbon_qml + and "width: 44" in ribbon_qml, + "SMV Injector branding must be readable at compact and normal widths") + device_controller = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") + workflow = source("WorkflowBar.qml") + require("const scenario = controller.startScenarioSnapshot()" in workflow + and "smartSession.requestStartWithScenario(scenario)" in workflow + and "smartSession.requestStart()" not in workflow, + "every START must transmit all 8 visible setpoints, never reuse stale board angles") + require('QStringLiteral("IA"), QStringLiteral("IB")' in device_controller + and 'QStringLiteral("IC"), QStringLiteral("IN")' in device_controller + and 'QStringLiteral("UA"), QStringLiteral("UB")' in device_controller + and 'QStringLiteral("UC"), QStringLiteral("UN")' in device_controller + and 'commands << QStringLiteral("START");' in device_controller + and 'const auto phaseMdeg = std::llround(angleMdeg);' in device_controller, + "START transaction must retain every channel and signed millidegree") + require("anchors.centerIn: parent" in source("CalmButton.qml") + and "Text.AlignHCenter" in source("RibbonAction.qml"), + "control captions must center on both axes") checkbox_qml = source("StudioDarkCheckBox.qml") require("width: 20" in checkbox_qml and "height: 20" in checkbox_qml, "checkbox geometry must remain unchanged by hover") require("hovered ? \"#edf5ff\"" in source("CalmButton.qml") - and "hovered ? \"#142334\"" in source("CalmButton.qml"), - "light hover background must always pair with dark caption") + and "hovered ? \"#142334\"" in source("CalmButton.qml") + and "/assets/material_dark/" in source("CalmButton.qml"), + "light hover background must pair with dark caption and icon") + combo = source("StudioDarkComboBox.qml") + require('choice.highlighted || choice.hovered ? "#142334"' in combo + and 'choice.highlighted || choice.hovered ? "#edf5ff"' in combo, + "Advanced dropdown options must be readable on white hover") + dark_tool = source("DarkToolButton.qml") + require('control.hovered ? "#142334"' in dark_tool + and 'control.hovered ? "#edf5ff"' in dark_tool, + "Advanced text/icon tool buttons must use dark captions on white hover") + require("background: Item { implicitWidth: 1; implicitHeight: 1 }" in checkbox_qml, + "checkbox hover must not render a second platform-native indicator") menu = source("StudioActionMenu.qml") cmake = (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8") diff --git a/docs/ARSTACK_SV_PTP_LOCK_BASELINE_20261018.md b/docs/ARSTACK_SV_PTP_LOCK_BASELINE_20261018.md new file mode 100644 index 000000000..2b9a5108e --- /dev/null +++ b/docs/ARSTACK_SV_PTP_LOCK_BASELINE_20261018.md @@ -0,0 +1,20 @@ +# ARStack Studio — SV/PTP non-regression baseline (2026-10-18) + +## Frozen engineering evidence +Operator's latest 120s ASIX PCAP contained 480,294 canonical 4I+4V SV frames on APPID 0x4000, svID ARSTACK_SV01, 4,000 fps, VLAN 0 PCP 4; **zero** smpCnt discontinuities (120 modulo-4000 wraps), every frame smpSynch=1 LOCAL, and no broadcast diagnostic mirror. There were 120 each of PTP Announce, Sync and matched Follow_Up on domain 0, all from ARStack's own local SOURCE. Correlation between host-received smpCnt=0 frames and PTP timestamp epochs yielded ~+48.9 us median / ~+67.2 us p95 across 120 matched observations; these are **Windows/ASIX host RX offsets**, not actual hardware sampling phase error. + +This is strong wire-level evidence for stable local-epoch alignment, **not** a certificate of external GM synchronization, cross-vendor acceptance, or microsecond hardware phase accuracy. + +## CI release guardrails +- Native Windows Preview must embed firmware from its own exact commit with phase servo ON, canonical MAC TX timestamp evidence ON, and diagnostic mirror OFF. Never quietly fall back to the old free-running firmware. +- Preserve fail-closed AUTO=0 when clock/sample-phase evidence is absent; qualified SOURCE-local AUTO=1 is allowed; never imply AUTO=2 global time traceability without evidence. +- Independently measure GPTimer sample-epoch phase (4us implementation threshold) and MAC SV transmission publication latency (nonzero is expected, admission range currently [-4us,+250us]); do not conflate the two. +- Never modify canonical EtherType, APPID 0x4000, svID, VLAN, confRev, ASDU 4I+4V payload, sample-counter continuity or PTP profile as collateral of UI work. +- Every START must use a fresh snapshot of all eight displayed waveform setpoints: IA IB IC IN UA UB UC UN, preserving signed angle millidegrees, magnitude scaling, enable flags and quality. A bare START that can reuse old board setpoints is forbidden. +- GUI angles are user setpoints, not independent firmware readback. **Do not claim actual phase injection matches until a same-run SET transaction, board SHOW phase_mdeg, and decoded SV waveform agree**. +- Default PTP Source/AUTO, Phasor ON and Wave ON; the operator should not encounter additional dialog gates for normal PTP ON and SMV Start. + +## Follow-up acceptance +With GUI Ia=-30°, Ib=-150°, Ic=+90°, capture a new immediately adjacent SV waveform *after* starting with that exact table. Verify the board's per-channel phase_mdeg (e.g. IA=-30000), then compare LS-fitted raw SV instantaneous waveform phase to the requested values. Repeat after STOP/START and reconnect. Verify PTP AUTO local acquisition and smpCnt continuity remain intact. + +This baseline intentionally does not claim SIPROTEC 5 lock or IED compatibility without actual subscriber evidence. From 0a3e9169d51c17366ba38b8e1615400246408472 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 13:28:39 +0700 Subject: [PATCH 15/15] fix(studio): readable operator UI and table phase integrity without PTP regression (#245) * fix(studio): strengthen phase table SSOT and operator workspace legibility * fix(studio): strengthen phase table SSOT and operator workspace legibility * fix(studio): strengthen phase table SSOT and operator workspace legibility * fix(studio): strengthen phase table SSOT and operator workspace legibility * fix(studio): strengthen phase table SSOT and operator workspace legibility * fix(qml): center button row in valid Item content root * fix(qml): keep checkbox label visible with single custom indicator * test(studio): enforce valid centered controls and one checkbox indicator * feat(studio): default verified-session PTP SOURCE ON without extra dialogs * test(studio): guard default PTP and phase SET millidegrees end-to-end * fix(studio): reuse existing one-shot PTP ON, never create duplicate timer * test(studio): validate compact stacked matrices and single pre-existing PTP default * docs(studio): pin observed PTP/SV continuity and GUI setpoint nonregression gate * test(studio): update legacy tiny logo expectation to legible branded ribbon * fix(studio): keep stacked signal matrices within minimum 960x620 Windows viewport * test(studio): preserve responsive bounded stacked height at 960x620 and desktop viewports --- apps/arstack_studio/qml/CalmButton.qml | 12 ++++--- apps/arstack_studio/qml/Main.qml | 36 ++++++++++++++----- apps/arstack_studio/qml/ModernRibbon.qml | 16 ++++----- .../arstack_studio/qml/StudioDarkCheckBox.qml | 3 ++ .../tests/check_operator_workspace.py | 36 +++++++++++++++---- ..._SV_PTP_NO_REGRESSION_BASELINE_20261018.md | 22 ++++++++++++ 6 files changed, 99 insertions(+), 26 deletions(-) create mode 100644 docs/STUDIO_SV_PTP_NO_REGRESSION_BASELINE_20261018.md diff --git a/apps/arstack_studio/qml/CalmButton.qml b/apps/arstack_studio/qml/CalmButton.qml index 924570197..66d0dd2b2 100644 --- a/apps/arstack_studio/qml/CalmButton.qml +++ b/apps/arstack_studio/qml/CalmButton.qml @@ -40,10 +40,13 @@ Button { tone === "danger" ? "#ffdce0" : tone === "accent" ? "#dcebff" : theme.textSoft - contentItem: Row { - // Center the complete icon+caption pair in both axes in Qt Controls. - anchors.centerIn: parent - spacing: control.iconSource.toString().length > 0 ? 7 : 0 + contentItem: Item { + implicitWidth: centeredContent.implicitWidth + implicitHeight: centeredContent.implicitHeight + Row { + id: centeredContent + anchors.centerIn: parent + spacing: control.iconSource.toString().length > 0 ? 7 : 0 Image { visible: control.iconSource.toString().length > 0 width: control.iconSize @@ -61,6 +64,7 @@ Button { anchors.verticalCenter: parent.verticalCenter Behavior on color { ColorAnimation { duration: 90 } } } + } } ToolTip.visible: hovered && toolTipText.length > 0 diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 05a29a3c6..30a7400ef 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -1069,7 +1069,21 @@ ApplicationWindow { text: "3-phase link" onToggled: { root.phaseLink = checked + // Linking phases changes future edit behavior only; + // never silently rewrite existing setpoints. root.markScenarioDirty() + if (workflowBar.injectionRunning && workflowBar.session + && workflowBar.session.liveControlReady) { + // Persist *all eight current table values* on + // live link changes; the displayed draft + // must never silently diverge from TX. + var ok = true + for (var group = 0; group < 2; ++group) + for (var row = 0; row < 4; ++row) + ok = root.sendSignal(group, row) && ok + if (ok) root.pendingApply = false + else root.showMessage("Not all live phases were sent. Stop/Start to apply the table.", true) + } } font.family: root.uiFont font.pixelSize: 9 @@ -1080,14 +1094,16 @@ ApplicationWindow { ColumnLayout { Layout.fillWidth: true - // Stack Voltage and Current vertically; allocate - // the available height between the two matrices. - Layout.fillHeight: true - Layout.preferredHeight: root.compactLayout ? 350 : 450 - // Allow 960x620 minimum viewport to shrink without - // clipping timing and quality rows; on larger - // viewports this column expands into spare space. - Layout.minimumHeight: root.compactLayout ? 180 : 210 + // Two stacked tables consume their actual content + // heights, never stretch four tiny rows into an + // oversized empty card at tall window sizes. + Layout.fillHeight: false + // Reserve vertical room for frequency, timing, + // and quality controls even at 960x620. Larger + // windows can use full natural matrix height. + Layout.preferredHeight: Math.min(410, Math.max(215, root.height - 365)) + Layout.maximumHeight: 410 + Layout.minimumHeight: 195 spacing: 8 SignalMatrix { id: voltageMatrix @@ -1095,6 +1111,8 @@ ApplicationWindow { objectName: "studioVoltageMatrix" Layout.fillWidth: true Layout.fillHeight: true + Layout.preferredHeight: 196 + Layout.minimumHeight: 92 theme: studioTheme controller: root device: device @@ -1114,6 +1132,8 @@ ApplicationWindow { objectName: "studioCurrentMatrix" Layout.fillWidth: true Layout.fillHeight: true + Layout.preferredHeight: 196 + Layout.minimumHeight: 92 theme: studioTheme controller: root device: device diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index bf9a737ab..22f18779c 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -47,23 +47,23 @@ Rectangle { spacing: 6 RowLayout { - Layout.preferredWidth: bar.compact ? 220 : 270 + Layout.preferredWidth: bar.compact ? 232 : 286 Layout.alignment: Qt.AlignVCenter spacing: 8 Rectangle { - Layout.preferredWidth: 56 - Layout.preferredHeight: 56 - width: 56 - height: 56 + Layout.preferredWidth: 64 + Layout.preferredHeight: 64 + width: 64 + height: 64 radius: 10 color: "#0d151d" border.width: 1 border.color: bar.theme.lineSoft Image { anchors.centerIn: parent - width: 44 - height: 44 + width: 53 + height: 53 source: Qt.resolvedUrl("../assets/material/smv_injector.svg") sourceSize.width: 68 sourceSize.height: 68 @@ -75,7 +75,7 @@ Rectangle { text: "SMV Injector" color: bar.theme.text font.family: bar.uiFont - font.pixelSize: bar.compact ? 20 : 22 + font.pixelSize: bar.compact ? 21 : 25 font.weight: Font.Bold elide: Text.ElideRight verticalAlignment: Text.AlignVCenter diff --git a/apps/arstack_studio/qml/StudioDarkCheckBox.qml b/apps/arstack_studio/qml/StudioDarkCheckBox.qml index 8fd0d320a..c47b6d679 100644 --- a/apps/arstack_studio/qml/StudioDarkCheckBox.qml +++ b/apps/arstack_studio/qml/StudioDarkCheckBox.qml @@ -8,6 +8,9 @@ CheckBox { property var theme property string uiFont: "Inter" implicitHeight: 32 + // The same single indicator is painted for normal, hover and focus. + // Do not overlay a native checkbox glyph on hover. + // Only the custom Rectangle paints the indicator; native style stays hidden. // Keep one fixed indicator geometry at rest, during hover and focus. // Native style decorations must not replace this explicit indicator. background: Item { implicitWidth: 1; implicitHeight: 1 } diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 11825422f..68087b5b6 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -53,10 +53,10 @@ def main() -> None: and "root.pendingApply" in main_qml and "pendingApply = true" in main_qml, "table edits must expose unsent setpoints without claiming wire readback") - require("Layout.minimumHeight: root.compactLayout ? 180 : 210" in main_qml - and "Layout.fillHeight: true" in main_qml + require("Layout.preferredHeight: Math.min(410, Math.max(215, root.height - 365))" in main_qml + and "Layout.maximumHeight: 410" in main_qml and "ColumnLayout {" in main_qml, - "voltage and current must stack vertically and fill the workspace") + "voltage and current must stack vertically with bounded content height") require('property bool ptpDefaultStartAttempted: false' in main_qml and 'root.ptpDefaultStartAttempted = true' in main_qml and '!root.startPtpRole("SOURCE")' in main_qml, @@ -76,6 +76,30 @@ def main() -> None: and "root.ptpRestoreChecks > 120" in main_qml and "root.ptpDesiredForSession = false" in main_qml, "PTP user intent restoration must be bounded, explicit and cancellable") + ribbon = source("ModernRibbon.qml") + checkbox = source("StudioDarkCheckBox.qml") + calm = source("CalmButton.qml") + require("Layout.preferredWidth: bar.compact ? 232 : 286" in ribbon + and "font.pixelSize: bar.compact ? 21 : 25" in ribbon, + "operator brand/logo must stay legible") + require("indicator: Rectangle {" in checkbox, + "checkbox must keep one stable custom indicator on hover") + require("anchors.centerIn: parent" in calm, + "button caption needs horizontal centering") + require("root.sendSignal(group, row)" in main_qml + and "Layout.fillHeight: false" in main_qml + and "Layout.preferredHeight: 196" in main_qml, + "tables and live sent phases must use explicit bounded layout") + require('property bool ptpDefaultStartAttempted: false' in main_qml + and 'root.startPtpRole("SOURCE")' in main_qml + and "device.deviceVerified" in main_qml, + "default PTP must reuse verified session ownership") + device_cpp = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") + require('commands << QStringLiteral("FREQ %1")' in device_cpp + and "const double phase = row.at(1).toDouble(&phaseOk);" in device_cpp + and "const double angleMdeg = phaseDegrees * 1000.0;" in device_cpp + and "commands << QStringLiteral(\"START\");" in device_cpp, + "START must send actual eight model phases as millidegree SET commands before START") matrix = source("SignalMatrix.qml") require("Layout.fillHeight: true" in matrix and "Layout.minimumHeight: 34" in matrix @@ -93,9 +117,9 @@ def main() -> None: and main_qml.index("id: voltageMatrix") < main_qml.index("id: currentMatrix"), "Voltage and Current need a vertical, flexible-height editor workspace") ribbon_qml = source("ModernRibbon.qml") - require("Layout.preferredWidth: bar.compact ? 220 : 270" in ribbon_qml - and "font.pixelSize: bar.compact ? 20 : 22" in ribbon_qml - and "width: 44" in ribbon_qml, + require("Layout.preferredWidth: bar.compact ? 232 : 286" in ribbon_qml + and "font.pixelSize: bar.compact ? 21 : 25" in ribbon_qml + and "width: 53" in ribbon_qml, "SMV Injector branding must be readable at compact and normal widths") device_controller = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") workflow = source("WorkflowBar.qml") diff --git a/docs/STUDIO_SV_PTP_NO_REGRESSION_BASELINE_20261018.md b/docs/STUDIO_SV_PTP_NO_REGRESSION_BASELINE_20261018.md new file mode 100644 index 000000000..dadb65f9a --- /dev/null +++ b/docs/STUDIO_SV_PTP_NO_REGRESSION_BASELINE_20261018.md @@ -0,0 +1,22 @@ +# ARStack Studio SV–PTP no-regression baseline — 18 Oct 2026 + +This document freezes observed evidence and operator-visible behavior without calling host-receiver timing a hardware accuracy certificate. + +## Known-good evidence + +The user's independent 120-second ASIX/Npcap capture demonstrated: 480,294 canonical SV frames (svID ARSTACK_SV01, APPID 0x4000), no diagnostic mirror, no smpCnt gaps/duplicates, 120 counter wraps, all smpSynch=1 under SOURCE + AUTO. PTP domain 0 carried 120 Announce, 120 Sync, and 120 matched Follow_Up. Host-relative smpCnt=0 to PTP epoch estimate median +48.9us, P95 +67.2us. This includes frame-publication and capture path delay; it is NOT a hardware sample-clock phase-error guarantee. + +## Non-regression gates + +1. Native Preview firmware must match the exact GUI commit and include PTP phase servo, MAC TX timing evidence, and broadcast mirror disabled. Never regress to a v0.1.1 bundle built from the old main firmware. +2. Local smpSynch=1 is qualified by actual sample timing; neither PTP visibility nor manual metadata suffices. Do not assert global smpSynch=2 for a local class-248 SOURCE. +3. At 50 Hz / 80 SPC, canonical SV rate is 4000 fps and smpCnt wraps 3999→0, with no healthy-stream discontinuities. +4. Every START flushes editor debounces and sends the eight current table setpoints as an ordered FREQ/SET/ENABLE/SHAPE/START batch; serial phase encoding is signed millidegrees (Ia −30.00° -> -30000). No silent reuse of stale ESP32-P4 setpoints. +5. Editing live phase-link behavior must use the same ListModels as the visible table and enqueue all affected phases. Do not claim on-wire equality without PCAP and timestamp-matched setpoints. +6. PTP SOURCE has exactly one owner for the verified-session default ON behavior; user OFF cancels it. Do not add duplicate startup timers, modal barriers, or write PTP/SMV clock state from presentation. +7. GUI: readable brand/logo, consistently centered buttons, dark text for pale hover, only one stable checkbox indicator, vertical voltage/current tables with bounded heights; preserve Windows DPI 100/150/175 acceptance. +8. GUI-only improvements must not alter Ethernet SV frames, PTP hardware epoch lease, timestamp/ISR behavior, wire quality, or smpCnt logic. + +## Next evidence for release + +Studio Qt CI, Windows Lifecycle and exact-head Native Preview must pass. After installation, inspect serial FREQ/SET phase commands and independent decoded waveforms for Ia −30°, Ib −150°, Ic +90° and the requested Ua/Ub/Uc. Capture >120 seconds after AUTO becomes local 1, include STOP/START and PTP SOURCE fault/recovery. Confirm no missing counters and stable epoch alignment. Physical synchronization accuracy and cross-vendor subscriber acceptance are separate from host timing screening.