Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions .github/workflows/processbus-dual-rc-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
name: Process Bus Simulator Dual Firmware RC Publisher

# This job PROMOTES a tested exact-PR-head preview. No additional IDF/Qt builds.
on:
push:
branches: [main]
paths:
- docs/releases/PROCESSBUS_DUAL_RC_LAUNCH.md
workflow_dispatch:
inputs:
source_pr:
description: "Merged source PR number with green native preview"
required: true
type: string

permissions:
contents: write
actions: read
checks: read
pull-requests: read

concurrency:
group: processbus-dual-rc1-publication
cancel-in-progress: false

env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: processbus-sim-dual-rc1-20261105

jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false

- name: Resolve merged PR and successful exact-head native preview
id: source
shell: bash
env:
SOURCE_PR_INPUT: ${{ inputs.source_pr }}
run: |
set -euo pipefail
if [[ "$GITHUB_EVENT_NAME" == "push" ]]; then
subject="$(git log -1 --format=%s)"
[[ "$subject" == release\(rc\):* ]] || { echo 'Not release(rc) merge' >&2; exit 1; }
pr="$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" --jq '[.[] | select(.merged_at != null)] | last | .number // empty')"
if [[ -z "$pr" ]]; then
pr="$(printf '%s' "$subject" | grep -oE '#[0-9]+' | tail -1 | tr -d '#' || true)"
fi
else
pr="$SOURCE_PR_INPUT"
fi
[[ "$pr" =~ ^[0-9]+$ ]] || { echo 'No source PR' >&2; exit 1; }
gh api "repos/$GITHUB_REPOSITORY/pulls/$pr" > /tmp/source-pr.json
jq -e --arg main "$GITHUB_SHA" \
'.merged_at != null and .merge_commit_sha == $main and .base.ref == "main"' \
/tmp/source-pr.json >/dev/null
head="$(jq -r '.head.sha' /tmp/source-pr.json)"
[[ "$head" =~ ^[a-f0-9]{40}$ ]] || exit 1
gh api "repos/$GITHUB_REPOSITORY/commits/$head/check-runs?per_page=100" > /tmp/checks.json
jq -e '.total_count >= 10 and ([.check_runs[] | select(.conclusion != "success")] | length == 0)' \
/tmp/checks.json >/dev/null
gh api "repos/$GITHUB_REPOSITORY/actions/workflows/arstack-studio-native-preview.yml/runs?event=pull_request&per_page=100" >/tmp/runs.json
run="$(jq -r --arg h "$head" '[.workflow_runs[] | select(.head_sha == $h and .conclusion == "success")] | sort_by(.created_at) | last | .id // empty' /tmp/runs.json)"
[[ "$run" =~ ^[0-9]+$ ]] || { echo 'Missing green exact-head native preview' >&2; exit 1; }
printf 'pr=%s\nhead=%s\nrun=%s\n' "$pr" "$head" "$run" >>"$GITHUB_OUTPUT"
echo "SOURCE PR=$pr HEAD=$head CI_RUN=$run MAIN=$GITHUB_SHA"

- name: Download already-tested artifact families (no rebuild)
shell: bash
run: |
set -euo pipefail
mkdir -p ci/windows ci/linux ci/firmware
gh run download "${{ steps.source.outputs.run }}" -R "$GITHUB_REPOSITORY" --name \
"arstack-studio-native-preview-win-x64-${{ steps.source.outputs.pr }}-${{ steps.source.outputs.head }}" --dir ci/windows
gh run download "${{ steps.source.outputs.run }}" -R "$GITHUB_REPOSITORY" --name \
arstack-studio-linux-esp32p4-dual-preview --dir ci/linux
gh run download "${{ steps.source.outputs.run }}" -R "$GITHUB_REPOSITORY" --name \
arstack-esp32p4-smv-firmware --dir ci/firmware

- name: Prove 2 revision images and paired Windows/Linux simulator artifacts
shell: bash
env:
SOURCE_HEAD: "${{ steps.source.outputs.head }}"
run: |
set -euo pipefail
python3 scripts/validate_esp32p4_revision_catalog.py ci/firmware
(cd ci/firmware && sha256sum -c SHA256SUMS.txt)
(cd ci/windows && sha256sum -c SHA256SUMS.txt)
(cd ci/linux && sha256sum -c ARStack-Studio-linux-x86_64.sha256)
python3 - <<'PY'
import hashlib, json, os, pathlib, re, zipfile
root=pathlib.Path('ci/firmware')
fw=json.loads((root/'firmware-manifest.json').read_text())
cat=json.loads((root/'firmware-catalog.json').read_text())
head=os.environ['SOURCE_HEAD']
assert re.fullmatch('[a-f0-9]{40}',head)
assert fw['sourceCommit']==cat['sourceCommit']==head
assert fw['buildId']==head[:16]
assert fw['phaseSyncPreview'] and fw['bootSimulatedGlobalGmEnabled']
assert fw['defaultPtpPolicy']=='INTERNAL_GM_ONLY'
assert cat['schema']=='arstack.studio.firmware.catalog.v2'
assert len(cat['images'])==2
assert {x['policy'] for x in cat['images']}=={'pre-v3','v3.x'}
hashes=set()
for x in cat['images']:
payload=(root/x['image']).read_bytes()
assert len(payload)>50000
digest=hashlib.sha256(payload).hexdigest()
assert digest==x['sha256'] and head[:16].encode() in payload
hashes.add(digest)
assert len(hashes)==2
win=list(pathlib.Path('ci/windows').glob('*-portable.zip'))
assert len(win)==1 and len(list(pathlib.Path('ci/windows').glob('*-setup.exe')))==1
assert len(list(pathlib.Path('ci/linux').glob('*.AppImage')))==1
with zipfile.ZipFile(win[0]) as f:
assert 'firmware/firmware-catalog.json' in f.namelist()
for x in cat['images']:
assert hashlib.sha256(f.read('firmware/'+x['image'])).hexdigest()==x['sha256']
print('PAIRED_DUAL_REVISION_SIMULATED_GM_RC: PASS',head)
PY

- name: Stage public RC assets with verifiable provenance
shell: bash
env:
SOURCE_HEAD: "${{ steps.source.outputs.head }}"
SOURCE_PR: "${{ steps.source.outputs.pr }}"
SOURCE_RUN: "${{ steps.source.outputs.run }}"
run: |
set -euo pipefail
mkdir release
cp ci/windows/*-setup.exe ci/windows/*-portable.zip release/
cp ci/linux/*.AppImage ci/linux/*.sha256 release/
cp ci/firmware/*.bin ci/firmware/firmware-manifest.json ci/firmware/firmware-catalog.json release/
cat >release/RELEASE_NOTES.md <<'NOTES'
# ARStack Process Bus Simulator — Dual ESP32-P4 Release Candidate

Public lab engineering release candidate: the existing ARStack Studio Qt app injects true Ethernet IEC 61850 SV from configurable SCL virtual 4I+4V Merging Units and emulates a GPS-style PTP Grandmaster with phase-aligned sample counters and simulated source/epoch. A physical GPS antenna or physical MU is not necessary for simulation.

Includes tested, matching Windows Inno Setup, Windows portable ZIP, Linux AppImage, separate pre-v3 and v3.x ESP32-P4 firmware images, revision catalog and SHA256 checksums. Studio selects a revision-compatible image from authorized ROM identity. Do not mix binaries from another release.

New v3.x PCB model compatibility, interruption recovery and three-MU aggregate throughput still require extended physical field testing, so this is an openly available **prerelease**, not a stable certification claim. Use on isolated test networks, not energized operational process bus equipment. Existing stable M3.H.3 is unchanged.
NOTES
python3 - <<'PY'
import json,os,pathlib
p={'schema':'arstack.release.provenance.v1',
'tag':os.environ['RELEASE_TAG'],'channel':'public-prerelease',
'sourceHead':os.environ['SOURCE_HEAD'],
'sourcePR':int(os.environ['SOURCE_PR']),
'nativePreviewRun':int(os.environ['SOURCE_RUN']),
'mergedMain':os.environ['GITHUB_SHA'],
'revisionPolicies':['pre-v3','v3.x']}
pathlib.Path('release/release-provenance.json').write_text(json.dumps(p,indent=2,sort_keys=True)+'\n')
PY
(cd release && sha256sum *.bin *.zip *.exe *.AppImage *.json > SHA256SUMS.txt)
ls -lah release

- name: Publish public prerelease (fail on existing tag)
shell: bash
run: |
set -euo pipefail
if gh release view "$RELEASE_TAG" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "Immutable tag already exists; abort" >&2
exit 1
fi
gh release create "$RELEASE_TAG" release/* -R "$GITHUB_REPOSITORY" \
--target "$GITHUB_SHA" --prerelease --latest=false \
--title "ARStack Process Bus Simulator — Dual Firmware RC1" \
--notes-file release/RELEASE_NOTES.md
12 changes: 12 additions & 0 deletions docs/releases/PROCESSBUS_DUAL_RC_LAUNCH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Process Bus Simulator — Dual ESP32-P4 public RC1 launch intent

**Candidate tag:** `processbus-sim-dual-rc1-20261105` (PUBLIC PRERELEASE, not stable).
**Authority:** `AGENTS.md` §0: emulate a real IEC 61850 Merging Unit and GPS-like PTP Grandmaster on isolated lab Ethernet. Do not gate on physical GNSS/UTC traceability or downgrade the deliberate simulated-global `smpSynch=2` profile.

This intent triggers the existing-preview **artifact-promotion** job only after the PR is merged into `main` with a `release(rc):` squash-merge title. It **does not add another Qt or ESP-IDF build**. The publisher resolves the just-merged PR and its exact-head successful native preview workflow, downloads the three already-verified artifact families, rechecks their source/firmware catalog identity, and publishes a GitHub prerelease with Windows portable/installer, Linux AppImage, pre-v3 and v3.x firmware images/catalog and checksums. Fails closed on any missing or mismatched evidence; does not touch `processbus-sim-m3h3-20261025`.

**Product scope:** independent canonical 4I+4V SV streams from SCL and selectable GPS-style simulated PTP SOURCE; the M4 integrated software can control three MUs on one sample grid. Lab SV/PTP packets are genuine Ethernet frames, generated electrical and time measurements are simulation inputs.

**Candidate acceptance:** existing PR-head Windows/Linux/ESP32 checks green, 2 distinct revision images from same source SHA, correct flash catalog and installer contents, intact schema/manifest hashes, preserved global-GM emulation and negative regression. Engineering limitations of new v3.x boards and full 3-MU physical HIL remain specifically identified, rather than inventing certification requirements or releasing unverified hardware promises.

**Release channel:** RC/pre-release only pending wider field compatibility testing; once accepted it may be promoted without changing historical release assets. Do not present this as a production energized-substation transmitter.
18 changes: 11 additions & 7 deletions embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -106,14 +106,18 @@ void record_transition(const SvSyncLossReason reason,
}

[[nodiscard]] std::optional<SmpSynchValue> 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.
// SOURCE is an intentional PTP Grandmaster simulator. The simulated
// global profile must behave like a GPS-class GM to an IEC 61850 test
// subscriber. Neither selecting the profile nor seeing PTP frames is
// enough: require the same qualified SV sample/MAC phase AND a fresh
// active simulated GM PTP TX lease before advertising smpSynch=2.
if (g_source_local_qualified.load(std::memory_order_acquire)) {
// An internally generated bench GM is not independently traceable
// to global time. A measured local sample/MAC phase can justify
// smpSynch=1, never smpSynch=2. Keep AUTO fail-closed globally.
return SmpSynchValue::local_synchronized;
bool simulated_global_gm_ready = false;
#if defined(ESP_PLATFORM) && CONFIG_AR_PTP_LAB_TX
simulated_global_gm_ready = ar_ptp_lab_bench_global_ready();
#endif
return ar::iec61850::time_sync::resolve_emulated_gm_smp_synch(
true, simulated_global_gm_ready);
}
return ar::iec61850::time_sync::qualified_sv_sync_value(
measured_value(), g_sv_timeline_phase_qualified.load(
Expand Down
16 changes: 16 additions & 0 deletions include/ariec61850/time_sync/smp_synch_policy.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,22 @@ struct SmpSynchDecision final {
}
};

/**
* Resolve the explicitly selected simulated PTP SOURCE's SV sync state.
* A simulated GPS-class GM is a first-class test-set feature: 2 is valid
* only while both its PTP TX evidence and the SV sample/MAC phase are
* qualified. Unqualified sources cannot advertise global sync. The caller
* retains the RECEIVER discipline path independently.
*/
[[nodiscard]] constexpr std::optional<SmpSynchValue> resolve_emulated_gm_smp_synch(
const bool sample_phase_mac_qualified,
const bool simulated_global_gm_ready) noexcept {
if (!sample_phase_mac_qualified) return std::nullopt;
return simulated_global_gm_ready
? SmpSynchValue::global_synchronized
: SmpSynchValue::local_synchronized;
}

/**
* Resolve what the SV publisher is allowed to advertise in smpSynch.
*
Expand Down
17 changes: 17 additions & 0 deletions tests/test_smp_synch_policy.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,22 @@ void test_auto_accepts_future_measured_state() {
require(!local.simulated(), "measured decision must not be marked simulated");
}

void test_simulated_global_gm_requires_live_phase_and_ptp_evidence() {
using ar::iec61850::time_sync::resolve_emulated_gm_smp_synch;
require(!resolve_emulated_gm_smp_synch(false, false).has_value(),
"inactive SOURCE cannot claim local or global sync");
require(!resolve_emulated_gm_smp_synch(false, true).has_value(),
"simulated GPS GM selection alone cannot claim smpSynch=2");
const auto local = resolve_emulated_gm_smp_synch(true, false);
require(local == SmpSynchValue::local_synchronized,
"qualified local SOURCE without global GM advertises smpSynch=1");
const auto global = resolve_emulated_gm_smp_synch(true, true);
require(global == SmpSynchValue::global_synchronized,
"qualified simulated GPS GM must advertise smpSynch=2");
require(!resolve_emulated_gm_smp_synch(false, true).has_value(),
"loss of sample/MAC phase revokes simulated global claim");
}

void test_receiver_auto_requires_fresh_ptp_and_real_timeline() {
using namespace ar::esp32p4::smv;
smp_synch_lab_set_mode(SvSyncPolicyMode::honest_unsynchronized);
Expand Down Expand Up @@ -173,6 +189,7 @@ void test_labels_are_stable_for_ui_and_evidence() {
int main() {
test_auto_is_safe_without_measured_lock();
test_auto_accepts_future_measured_state();
test_simulated_global_gm_requires_live_phase_and_ptp_evidence();
test_receiver_auto_requires_fresh_ptp_and_real_timeline();
test_internal_sync_recorder_captures_cause_and_bounded_history();
test_all_lab_compatibility_modes();
Expand Down
Loading