Skip to content

P0-B: atomic running 4I+4V live setpoints with firmware commit/readback #1158

P0-B: atomic running 4I+4V live setpoints with firmware commit/readback

P0-B: atomic running 4I+4V live setpoints with firmware commit/readback #1158

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]
paths:
- 'embedded/esp32p4_smv_injector/**'
- 'include/ariec61850/asn1/**'
- 'include/ariec61850/ethernet/**'
- 'include/ariec61850/mms/utc_time.hpp'
- 'include/ariec61850/time_sync/**'
- 'include/ariec61850/sampled_values/**'
- 'src/asn1/**'
- 'src/ethernet/**'
- 'src/mms/utc_time.cpp'
- 'src/time_sync/**'
- 'src/sampled_values/**'
- '.github/workflows/esp32p4-smv-injector.yml'
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- 'embedded/esp32p4_smv_injector/**'
- 'include/ariec61850/asn1/**'
- 'include/ariec61850/ethernet/**'
- 'include/ariec61850/mms/utc_time.hpp'
- 'include/ariec61850/time_sync/**'
- 'include/ariec61850/sampled_values/**'
- 'src/asn1/**'
- 'src/ethernet/**'
- 'src/mms/utc_time.cpp'
- 'src/time_sync/**'
- 'src/sampled_values/**'
- '.github/workflows/esp32p4-smv-injector.yml'
permissions:
contents: read
concurrency:
group: esp32p4-smv-${{ github.workflow }}-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
ARSTACK_FIRMWARE_VERSION: '0.1.1'
ARSTACK_PROTOCOL_VERSION: '1'
# Reusable PR workflows expose github.sha as a synthetic merge SHA.
# Compile identity and manifest must use the exact checked-out PR HEAD.
ARSTACK_SOURCE_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }}
jobs:
build-esp32p4:
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-latest
timeout-minutes: 25
env:
# ESP-IDF image already enables ccache. Persist only compiler objects,
# never firmware images or sourceCommit manifests.
CCACHE_DIR: ${{ github.workspace }}/.cache/ccache
CCACHE_MAXSIZE: 350M
CCACHE_COMPILERCHECK: content
container:
image: espressif/idf:v5.5.4
defaults:
run:
shell: bash
working-directory: embedded/esp32p4_smv_injector
steps:
- uses: actions/checkout@v4
with:
# PR packaging must use exactly the reviewed source SHA, not a
# synthetic merge SHA that may change between queue and build.
ref: ${{ github.event.pull_request.head.sha || github.sha }}
# QML-only commits share a bounded compiler-object cache; the firmware
# itself is always rebuilt and stamped with the exact checkout SHA.
- name: Restore ESP-IDF compiler object cache
uses: actions/cache@v4
with:
path: ${{ env.CCACHE_DIR }}
key: esp32p4-idf554-ccache-v1-${{ runner.os }}-${{ hashFiles('embedded/esp32p4_smv_injector/**', 'include/ariec61850/asn1/**', 'include/ariec61850/ethernet/**', 'include/ariec61850/mms/utc_time.hpp', 'include/ariec61850/time_sync/**', 'include/ariec61850/sampled_values/**', 'src/asn1/**', 'src/ethernet/**', 'src/mms/utc_time.cpp', 'src/time_sync/**', 'src/sampled_values/**', '.github/workflows/esp32p4-smv-injector.yml') }}
restore-keys: |
esp32p4-idf554-ccache-v1-${{ runner.os }}-
- name: Reset compiler cache statistics
run: |
if command -v ccache >/dev/null 2>&1; then
ccache --zero-stats
echo "Compiler cache ready at ${CCACHE_DIR}"
else
echo 'ccache unavailable; preserve mandatory standard build'
fi
- name: Pin semantic firmware build ID to the exact checkout
run: |
set -euo pipefail
if [[ ! "$ARSTACK_SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then
echo 'Missing exact firmware source commit' >&2
exit 1
fi
checkout_commit="$(git -c safe.directory="$GITHUB_WORKSPACE" rev-parse HEAD)"
if [[ "$checkout_commit" != "$ARSTACK_SOURCE_COMMIT" ]]; then
echo "Firmware checkout drift: $checkout_commit != $ARSTACK_SOURCE_COMMIT" >&2
exit 1
fi
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
grep -qx 'CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y' sdkconfig
echo 'ESP32-P4 pre-v3 build policy: PASS'
- name: Verify production PTP-P2 profile
run: |
set -euo pipefail
grep -qx 'CONFIG_ETH_TRANSMIT_MUTEX=y' sdkconfig
grep -qx 'CONFIG_AR_PTP_LAB_TX=y' sdkconfig
grep -qx 'CONFIG_AR_PTP_OPERATING_ROLE=0' sdkconfig
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=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'
- name: Verify realtime START, transport health and telemetry boundary policy
run: |
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
source = Path('main/app_main.cpp').read_text(encoding='utf-8')
cmake = Path('main/CMakeLists.txt').read_text(encoding='utf-8')
smp_synch = Path('main/smp_synch_lab.cpp').read_text(encoding='utf-8')
assert 'find_fixed_field' not in source
assert 'AR_SMP_SYNCH_INTERCEPT_TX' not in cmake
assert 'encode_into_with_layout' in source
assert 'sample_synchronization_offset' in source
assert 'smp_synch_lab_decision()' in source
assert 'wire_field_offsets.hpp' not in smp_synch
assert 'esp_eth_transmit' not in smp_synch
assert 'observe_canonical_tx_result' not in smp_synch
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]
# 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<std::uint32_t>::is_always_lock_free);' in source
assert 'g_sample_tick_total.fetch_add(1U, std::memory_order_relaxed);' in source
assert 'elapsed_ticks) * 1000000ULL' in source
assert 'rate_baseline_valid = true;' in source
assert 'kTransportFailureStopThreshold = 400U' in source
assert 'live_control_force_stop();' in source
assert 'SV transport fault:' in source
print('Realtime boundary policy: PASS - encoder-owned WirePlan + direct smpSynch patch + one canonical TX failure authority')
PY
- name: Build firmware
run: source /opt/esp/idf/export.sh && idf.py build
- name: Report ESP-IDF compiler cache use
if: always()
run: |
if command -v ccache >/dev/null 2>&1; then
ccache --show-stats
fi
- name: Create single-image recovery firmware
run: |
set -euo pipefail
source /opt/esp/idf/export.sh
image="arstack-esp32p4-smv-${ARSTACK_FIRMWARE_VERSION}.bin"
# idf.py runs esptool's merge_bin from inside the build directory.
# Passing build/<name> therefore resolves to build/build/<name> and fails.
idf.py merge-bin -o "${image}" -f raw
test -s "build/${image}"
- name: Create release manifest and SHA-256 contract
# Job-scoped ARSTACK_SOURCE_COMMIT is the exact checkout identity.
run: |
set -euo pipefail
image="arstack-esp32p4-smv-${ARSTACK_FIRMWARE_VERSION}.bin"
sha="$(sha256sum "build/${image}" | awk '{print $1}')"
python3 - "$image" "$sha" "$ARSTACK_SOURCE_COMMIT" <<'PY'
import json
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 = {
"schema": "arstack.studio.firmware.v1",
"product": "ARStack ESP32-P4 SMV Injector",
"chip": "esp32p4",
"chipRevisionPolicy": "pre-v3",
"version": os.environ["ARSTACK_FIRMWARE_VERSION"],
"protocol": int(os.environ["ARSTACK_PROTOCOL_VERSION"]),
"capabilities": ["SMV-4I4V", "PROFILE", "PROFILE-BINARY-V1", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"],
"flashOffset": 0,
"image": image,
"sha256": sha256,
"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)
handle.write("\n")
PY
python3 - <<'PY'
import hashlib, json, pathlib
root = pathlib.Path('build')
manifest = json.loads((root / 'firmware-manifest.json').read_text(encoding='utf-8'))
payload = (root / manifest['image']).read_bytes()
actual = hashlib.sha256(payload).hexdigest()
assert manifest['schema'] == 'arstack.studio.firmware.v1'
assert manifest['chip'] == 'esp32p4'
assert manifest['chipRevisionPolicy'] == 'pre-v3'
assert manifest['flashOffset'] == 0
assert manifest['protocol'] == 1
assert 'PTP-P2' in manifest['capabilities']
assert 'SMPSYNCH-AUTO' in manifest['capabilities']
assert 'PROFILE-BINARY-V1' in manifest['capabilities']
assert __import__('re').fullmatch(r'[0-9a-f]{40}', manifest['sourceCommit'])
expected_build_id = manifest['sourceCommit'][:16]
assert manifest['buildId'] == expected_build_id
# The app itself must advertise the identity in IDENTIFY. A valid SHA
# and sourceCommit in manifest alone cannot prove which ELF was built.
assert expected_build_id.encode('ascii') in payload, 'Firmware image does not contain the promised semantic build identity'
assert actual == manifest['sha256']
print(f"Firmware package: PASS · {manifest['image']} · PTP-P2 · pre-v3 · SHA-256 {actual}")
PY
- name: Stage Studio firmware package
run: |
set -euo pipefail
mkdir -p build/studio-firmware
cp "build/arstack-esp32p4-smv-${ARSTACK_FIRMWARE_VERSION}.bin" build/studio-firmware/
cp build/firmware-manifest.json build/studio-firmware/
sha256sum build/studio-firmware/* > build/studio-firmware/SHA256SUMS.txt
- name: Upload ESP32-P4 firmware package
uses: actions/upload-artifact@v4
with:
name: arstack-esp32p4-smv-firmware
path: embedded/esp32p4_smv_injector/build/studio-firmware/
if-no-files-found: error
retention-days: 14