Repository navigation
P0-B: atomic running 4I+4V live setpoints with firmware commit/readback #1158
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |