Skip to content

M2A: bounded continuous PTP–SMV phase authority and guarded ESP32-P4 observer #768

M2A: bounded continuous PTP–SMV phase authority and guarded ESP32-P4 observer

M2A: bounded continuous PTP–SMV phase authority and guarded ESP32-P4 observer #768

name: ESP32-P4 PTP Lab Profile
on:
workflow_dispatch:
push:
branches: [main]
paths:
- 'embedded/esp32p4_smv_injector/**'
- 'include/ariec61850/time_sync/**'
- 'src/time_sync/**'
- 'include/ariec61850/ethernet/**'
- 'src/ethernet/**'
- '.github/workflows/esp32p4-ptp-lab.yml'
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- 'embedded/esp32p4_smv_injector/**'
- 'include/ariec61850/time_sync/**'
- 'src/time_sync/**'
- 'include/ariec61850/ethernet/**'
- 'src/ethernet/**'
- '.github/workflows/esp32p4-ptp-lab.yml'
permissions:
contents: read
concurrency:
group: esp32p4-ptp-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
phase-guard-checkpoint:
# Explicit one-job Draft checkpoint: no two-role firmware matrix.
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.draft && contains(github.event.pull_request.body, '[run-phase-guard]') }}
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: espressif/idf:v5.5.4
steps:
- uses: actions/checkout@v4
- name: Portable strict phase regression
shell: bash
run: |
source /opt/esp/idf/export.sh
cmake -S tests/ptp_profile -B build-phase-guard -DCMAKE_BUILD_TYPE=Release
cmake --build build-phase-guard --target sv_ptp_phase_authority_tests sv_tx_egress_evidence_tests --parallel 2
ctest --test-dir build-phase-guard --output-on-failure -R 'sv_ptp_phase_authority_tests|sv_tx_egress_evidence_tests'
- name: Compile guarded ESP32-P4 SOURCE firmware
shell: bash
working-directory: embedded/esp32p4_smv_injector
run: |
source /opt/esp/idf/export.sh
idf.py set-target esp32p4
python3 - <<'PY'
from pathlib import Path
p = Path("sdkconfig")
s = p.read_text(encoding="utf-8")
def put_bool(source, key, enabled):
source = "\n".join(line for line in source.splitlines()
if line != f"# {key} is not set" and not line.startswith(key + "="))
return source + "\n" + (f"{key}=y" if enabled else f"# {key} is not set") + "\n"
s = put_bool(s, "CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL", True)
s = put_bool(s, "CONFIG_AR_SMV_BROADCAST_MIRROR", False)
s = put_bool(s, "CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL", True)
p.write_text(s, encoding="utf-8")
PY
idf.py reconfigure
grep -qx 'CONFIG_AR_PTP_LAB_TX=y' sdkconfig
grep -qx 'CONFIG_AR_SMV_LIVE_TX=y' sdkconfig
grep -qx 'CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y' sdkconfig
grep -qx '# CONFIG_AR_SMV_BROADCAST_MIRROR is not set' sdkconfig
grep -qx 'CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL=y' sdkconfig
idf.py build
build-esp32p4-ptp:
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
strategy:
fail-fast: false
matrix:
include:
- role_name: source
role_value: 0
- role_name: receiver
role_value: 1
name: build-esp32p4-ptp-${{ matrix.role_name }}
runs-on: ubuntu-latest
timeout-minutes: 25
container:
image: espressif/idf:v5.5.4
defaults:
run:
shell: bash
working-directory: embedded/esp32p4_smv_injector
steps:
- uses: actions/checkout@v4
- name: Select ESP32-P4 target
run: source /opt/esp/idf/export.sh && idf.py set-target esp32p4
- name: Verify PTP source readiness and ESP-IDF 5.5 TX contract
shell: bash
run: |
python - <<'PY'
from pathlib import Path
eth = Path('main/ethernet_port.c').read_text(encoding='utf-8')
app = Path('main/app_main.cpp').read_text(encoding='utf-8')
ptp = Path('main/ptp_lab_task.cpp').read_text(encoding='utf-8')
receiver = Path('main/ptp_receiver_task.cpp').read_text(encoding='utf-8')
# Never allow the old premature boot path back into Ethernet init.
if 'ar_ptp_lab_try_start(handle)' in eth:
raise SystemExit('PTP must not auto-start inside Ethernet initialization before app_main starts Ethernet')
if 'ESP_ERROR_CHECK(esp_eth_start(eth_handle));' not in app:
raise SystemExit('app_main must explicitly start Ethernet before operator-controlled PTP can run')
# ESP-IDF 5.5 esp_eth_transmit_ctrl_vargs forwards argc as the number
# of variadic arguments. ESP32-P4 EMAC computes buf_num = argc / 2,
# so one frame is two varargs: buffer pointer + buffer length.
tx_signature = '2U,\n frame.data(),\n frame.size()'
if tx_signature not in ptp:
raise SystemExit('PTP SOURCE timestamp TX must pass argc=2 for one buffer/length frame on ESP-IDF 5.5')
if tx_signature not in receiver:
raise SystemExit('PTP RECEIVER timestamp TX must pass argc=2 for one buffer/length frame on ESP-IDF 5.5')
if 'argc counts buffer/length pairs' in ptp or 'argc counts buffer/length pairs' in receiver:
raise SystemExit('stale/incorrect esp_eth_transmit_ctrl_vargs argc documentation remains')
if 'PTP hardware-timestamp TX failed:' not in ptp or 'without a valid hardware timestamp' not in ptp:
raise SystemExit('PTP SOURCE must distinguish driver TX errors from invalid timestamp evidence')
# Admission truth is the stronger bounded runtime contract: explicit
# operator start is accepted only after real Announce + Sync + Follow_Up TX.
required = [
'const bool started = ar_ptp_lab_try_start(s_eth_handle);',
'ar_esp32p4_restore_normal_emac();',
'g_ptp_ready',
'PTP source readiness timeout',
'g_announce_sent.load',
'g_sync_sent.load',
'g_follow_up_sent.load',
'return g_ptp_ready.load',
]
combined = eth + '\n' + ptp
missing = [token for token in required if token not in combined]
if missing:
raise SystemExit('PTP verified-start contract missing: ' + ', '.join(missing))
print('PTP contract: post-Ethernet start + ESP-IDF 5.5 argc=2 timestamp TX + verified core timing + rollback')
PY
- name: Enable guarded PTP-P2 profile
shell: bash
run: |
set_config() {
local key="$1"
local value="$2"
if grep -q "^${key}=" sdkconfig; then
sed -i "s|^${key}=.*|${key}=${value}|" sdkconfig
elif grep -q "^# ${key} is not set$" sdkconfig && [ "$value" = "y" ]; then
sed -i "s|^# ${key} is not set$|${key}=y|" sdkconfig
else
echo "${key}=${value}" >> sdkconfig
fi
}
set_config CONFIG_AR_PTP_LAB_TX y
set_config CONFIG_AR_PTP_OPERATING_ROLE ${{ matrix.role_value }}
if [ "${{ matrix.role_name }}" = "source" ]; then
# Compile the guarded M2A control-plane/ISR adapter in a dedicated
# SOURCE build, without enabling it in the public default image.
set_config CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL y
fi
set_config CONFIG_AR_PTP_DOMAIN 0
set_config CONFIG_AR_PTP_TRANSPORT_SPECIFIC 1
set_config CONFIG_AR_PTP_PORT_NUMBER 2
set_config CONFIG_AR_PTP_VLAN y
set_config CONFIG_AR_PTP_VLAN_ID 100
set_config CONFIG_AR_PTP_VLAN_PRIORITY 4
set_config CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS 1000
set_config CONFIG_AR_PTP_SYNC_INTERVAL_MS 1000
set_config CONFIG_AR_PTP_RESPOND_PDELAY y
set_config CONFIG_AR_PTP_PDELAY_REQUEST_INTERVAL_MS 1000
set_config CONFIG_AR_PTP_PRIORITY1 120
set_config CONFIG_AR_PTP_PRIORITY2 121
set_config CONFIG_AR_PTP_CLOCK_CLASS 248
set_config CONFIG_AR_PTP_CLOCK_ACCURACY 254
set_config CONFIG_AR_PTP_OFFSET_SCALED_LOG_VARIANCE 65535
set_config CONFIG_AR_PTP_TIME_SOURCE 160
set_config CONFIG_AR_PTP_CURRENT_UTC_OFFSET 37
set_config CONFIG_AR_PTP_MAX_PATH_DELAY_NS 5000000
set_config CONFIG_AR_PTP_MAX_PATH_JITTER_NS 100000
set_config CONFIG_AR_PTP_LOCK_OFFSET_NS 2000
set_config CONFIG_AR_PTP_UNLOCK_OFFSET_NS 20000
set_config CONFIG_AR_PTP_PHASE_STEP_THRESHOLD_NS 1000000
set_config CONFIG_AR_PTP_LOCK_REQUIRED_SAMPLES 8
set_config CONFIG_AR_PTP_SYNC_TIMEOUT_MS 3000
set_config CONFIG_AR_PTP_HOLDOVER_TIMEOUT_MS 5000
set_config CONFIG_AR_PTP_MAX_FREQ_ADJ_PPB 100000
source /opt/esp/idf/export.sh
idf.py reconfigure
grep -E '^CONFIG_AR_PTP_(LAB_TX|OPERATING_ROLE|DOMAIN|TRANSPORT_SPECIFIC|PORT_NUMBER|VLAN|VLAN_ID|VLAN_PRIORITY|PDELAY_REQUEST_INTERVAL_MS|LOCK_OFFSET_NS|UNLOCK_OFFSET_NS|LOCK_REQUIRED_SAMPLES|MAX_FREQ_ADJ_PPB)=' sdkconfig
grep -q '^CONFIG_AR_PTP_LAB_TX=y$' sdkconfig
grep -q '^CONFIG_AR_PTP_OPERATING_ROLE=${{ matrix.role_value }}$' sdkconfig
grep -q '^CONFIG_AR_PTP_TRANSPORT_SPECIFIC=1$' sdkconfig
grep -q '^CONFIG_AR_PTP_PORT_NUMBER=2$' sdkconfig
grep -q '^CONFIG_AR_PTP_VLAN_ID=100$' sdkconfig
grep -q '^CONFIG_AR_PTP_LOCK_OFFSET_NS=2000$' sdkconfig
grep -q '^CONFIG_AR_PTP_LOCK_REQUIRED_SAMPLES=8$' sdkconfig
if [ "${{ matrix.role_name }}" = "source" ]; then
grep -q '^CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y$' sdkconfig
fi
- name: Build PTP-P2 firmware
run: source /opt/esp/idf/export.sh && idf.py build