Skip to content
Closed
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
71 changes: 71 additions & 0 deletions .github/workflows/arstack-studio-native-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ jobs:
windows-preview:
needs: firmware
if: ${{ !github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository }}
permissions:
contents: write # Direct immutable engineering prerelease, never stable
runs-on: windows-2022
timeout-minutes: 40
env:
Expand Down Expand Up @@ -380,6 +382,75 @@ jobs:
Remove-Item $note -Force
Write-Host 'Actual v0.1.1 NSIS to Inno + owned cleanup + data preservation: PASS'

- name: Publish verified direct Smart Inno EXE (exact SHA engineering prerelease)
if: github.event.pull_request.head.ref == 'feat/sv-ed21-rate-metadata-20261022'
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
run: |
$ErrorActionPreference = 'Stop'
$sha = $env:PREVIEW_SHA.ToLowerInvariant()
if ($sha -cnotmatch '^[0-9a-f]{40}
uses: actions/upload-artifact@v4
Comment on lines +393 to +394

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Repair the malformed preview workflow

In the windows-preview job, the run: | block ends prematurely at the dedented uses: line, leaving the SHA regex unterminated and line 403 as an invalid top-level YAML key. A YAML parse reports could not find expected ':', so GitHub cannot load this workflow and none of its firmware or Windows preview jobs can run; keep the complete SHA check inside the PowerShell block and remove the misplaced duplicate artifact step.

Useful? React with 👍 / 👎.

with:
name: arstack-studio-native-preview-win-x64-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}
path: |
dist/ARStack-Studio-Preview-win-x64-setup.exe
dist/ARStack-Studio-Preview-win-x64-portable.zip
dist/SHA256SUMS.txt
if-no-files-found: error
retention-days: 7
) { throw 'Invalid immutable preview source SHA' }
$checked = (git rev-parse HEAD).Trim().ToLowerInvariant()
if ($checked -cne $sha) { throw "GUI/firmware head drift $checked != $sha" }
$short = $sha.Substring(0,12)
$tag = "ptp-sv-bench-$short"
$installer = Join-Path $PWD 'dist\ARStack-Studio-Preview-win-x64-setup.exe'
$manifestFile = Join-Path $PWD 'firmware-package\firmware-manifest.json'
if (-not (Test-Path -LiteralPath $installer -PathType Leaf) -or
-not (Test-Path -LiteralPath $manifestFile -PathType Leaf)) {
throw 'Missing tested Inno executable or matched firmware manifest'
}
$manifest = Get-Content -Raw -LiteralPath $manifestFile | ConvertFrom-Json
$imageFile = Join-Path $PWD ("firmware-package\" + $manifest.image)
if ($manifest.sourceCommit -cne $sha -or
-not (Test-Path -LiteralPath $imageFile -PathType Leaf) -or
(Get-FileHash -LiteralPath $imageFile -Algorithm SHA256).Hash.ToLowerInvariant() -cne
$manifest.sha256.ToLowerInvariant()) {
throw 'Exact firmware source or binary digest mismatch before publication'
}
$filename = "ARStack-Studio-0.1.1-ptp-sv-bench.$short-win-x64-setup.exe"
$direct = Join-Path $PWD ("dist\" + $filename)
Copy-Item -LiteralPath $installer -Destination $direct -Force
$sha256 = (Get-FileHash -LiteralPath $direct -Algorithm SHA256).Hash.ToLowerInvariant()
$sums = Join-Path $PWD 'dist\SHA256-INNO.txt'
"$sha256 $filename" | Set-Content -LiteralPath $sums -Encoding ascii
$notes = Join-Path $PWD 'dist\ptp-sv-preview-notes.md'
@(
'ARStack ESP32-P4 PTP/SV engineering bench prerelease — NOT stable / NOT relay-FAT qualified.'
"Exact GUI and firmware source SHA: $sha"
"Firmware binary SHA256: $($manifest.sha256)"
"Windows Inno setup SHA256: $sha256"
'Built and smoke-tested together in one Windows + ESP32 CI workflow.'
'Physical SIPROTEC GM LOCKED, SMV ACCEPTED and 1-hour soak are not asserted.'
'Never install on a production substation from this bench preview.'
) | Set-Content -LiteralPath $notes -Encoding utf8
gh release view $tag --repo $env:GITHUB_REPOSITORY *> $null
if ($LASTEXITCODE -eq 0) { throw "Immutable bench tag already exists: $tag" }
gh release create $tag $direct $sums --repo $env:GITHUB_REPOSITORY --target $sha --title "ARStack PTP/SV bench $short" --notes-file $notes --prerelease --latest=false
if ($LASTEXITCODE -ne 0) { throw 'Direct Inno prerelease creation failed' }
$published = gh release view $tag --repo $env:GITHUB_REPOSITORY --json tagName,isPrerelease,assets | ConvertFrom-Json
if ($LASTEXITCODE -ne 0 -or -not $published.isPrerelease -or
$published.tagName -cne $tag -or
-not @($published.assets | Where-Object { $_.name -ceq $filename }).Count) {
throw 'Published direct .exe cannot be verified'
}
$url = "https://github.com/$env:GITHUB_REPOSITORY/releases/download/$tag/$filename"
"Verified direct Smart Inno EXE: $url" | Out-File $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"Setup SHA256: $sha256" | Out-File $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "DIRECT_INNO_EXE=$url"
Write-Host "DIRECT_INNO_SHA256=$sha256"

- name: Upload GUI-only Windows preview packages
uses: actions/upload-artifact@v4
with:
Expand Down
9 changes: 9 additions & 0 deletions apps/arstack_studio/qml/ProfileInspector.qml
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,15 @@ SurfacePanel {
font.family: panel.monoFont
font.pixelSize: panel.theme.captionSize
}
Label { text: "SV wire"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize }
Label {
text: panel.profile.includeSampleRate
? "smpRate " + panel.profile.wireSmpRate + " · smpMod " + panel.profile.wireSmpMod
: "smpRate / smpMod omitted (optional)"
color: panel.theme.textSoft
font.family: panel.monoFont
font.pixelSize: panel.theme.captionSize
}
Label { text: "Frames"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize }
Label {
text: panel.profile.frameRateHz
Expand Down
46 changes: 46 additions & 0 deletions apps/arstack_studio/qml/PtpExpertPanel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,17 @@ SurfacePanel {
: panel.device.smpSynchMeasured
? "MEASURED SYNC FLAG · verify sample-phase evidence independently"
: "FREE-RUN / UNQUALIFIED · PTP packets do not prove SV sample lock"
readonly property string gmTxEvidence: panel.sourceRole && panel.device.ptpRunning
? (panel.sourceEvidence ? "TX OBSERVED" : "TX ACQUIRING")
: "NOT TRANSMITTING"
readonly property string svPhaseEvidence: panel.device.smpSynchSimulated
? "MANUAL FLAG ONLY"
: panel.device.smpSynchMeasured
? "MEASURED · smpSynch " + panel.device.smpSynchValue
: "NOT QUALIFIED"
// Firmware cannot observe another device's PTP servo or SV acceptance.
// Do not manufacture a relay LOCKED state from local Announce counters.
readonly property string relayLockEvidence: "NOT OBSERVED · CHECK RELAY"
readonly property bool receiverRole: activeRole === "RECEIVER"
readonly property bool sourceEvidence: Number(panel.device.ptpAnnounceSent) > 0
&& Number(panel.device.ptpSyncSent) > 0
Expand Down Expand Up @@ -286,6 +297,41 @@ SurfacePanel {
}
}

Rectangle {
Layout.fillWidth: true
implicitHeight: 52
radius: 7
color: panel.theme.panelAlt
border.width: 1
border.color: panel.theme.lineSoft

RowLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 10
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Label { text: "PTP GM TX"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 }
Label { text: panel.gmTxEvidence; color: panel.sourceEvidence && panel.sourceRole ? panel.theme.green : panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 10 }
}
Rectangle { width: 1; Layout.fillHeight: true; color: panel.theme.lineSoft }
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Label { text: "SV phase / MAC"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 }
Label { text: panel.svPhaseEvidence; color: panel.device.smpSynchMeasured && !panel.device.smpSynchSimulated ? panel.theme.green : panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 10 }
}
Rectangle { width: 1; Layout.fillHeight: true; color: panel.theme.lineSoft }
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Label { text: "Relay follower"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 }
Label { text: panel.relayLockEvidence; color: panel.theme.muted; font.family: panel.monoFont; font.pixelSize: 10 }
}
}
}

StudioDarkCheckBox {
id: benchGmCheck
Layout.fillWidth: true
Expand Down
9 changes: 9 additions & 0 deletions apps/arstack_studio/src/SclProfileModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,15 @@ QVariantMap SclProfileModel::profileToVariantMap(const SvPublisherProfile& p) co
map.insert(QStringLiteral("channelLeafCount"), static_cast<qulonglong>(p.channels.size()));
map.insert(QStringLiteral("includeDataSet"), p.asdu_options.data_set);
map.insert(QStringLiteral("includeSampleRate"), p.asdu_options.sample_rate);
map.insert(QStringLiteral("wireSmpRate"),
p.asdu_options.sample_rate
? QVariant::fromValue(static_cast<qulonglong>(p.timing.configured_sample_rate))
: QVariant{});
map.insert(QStringLiteral("wireSmpMod"),
p.asdu_options.sample_rate
? QVariant::fromValue(p.timing.sampling_basis == SvSampleMode::samples_per_period
? 0 : p.timing.sampling_basis == SvSampleMode::samples_per_second ? 1 : -1)
Comment on lines +527 to +528

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Map SecPerSample to smpMod 2

When an opened SCL uses the supported SecPerSample/SecPerSmp sampling basis with SmvOpts@sampleRate=true, the compiler produces SvSampleMode::seconds_per_sample, but this ternary falls through to -1. The new inspector therefore reports smpMod -1 instead of the corresponding wire value 2; handle the third known mode explicitly and reserve the fallback for genuinely unknown values.

AGENTS.md reference: AGENTS.md:L300-L301

Useful? React with 👍 / 👎.

: QVariant{});
map.insert(QStringLiteral("sampleSynchronizedField"), p.asdu_options.sample_synchronized);

map.insert(QStringLiteral("iec61869Present"), p.iec61869_9.has_value());
Expand Down
8 changes: 5 additions & 3 deletions apps/arstack_studio/tests/check_operator_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,18 @@ def main() -> None:
and "local_acquisition.observe_preflight(" in firmware_source
and "local_acquisition.observe_live(" in firmware_source
and "local_acquisition.reset();" in firmware_source
and "live_clean=%lu/%lu warm_phase=%lu/%lu live_age=%lu" in firmware_source
and "live_clean=%lu/%lu source_fast=%lu/%lu warm_phase=%lu/%lu " in firmware_source
and "SvSourceSelfClockAcquisition" in sv_gate
and "source_fast" in firmware_source
and "kRequiredCleanEpochs = 10U" in sv_gate
and "kWarmRequiredLiveEpochs = 3U" in sv_gate
and "kMaxWarmMacAcquisitionEpochs = 8U" in sv_gate
and "if (had_mac_evidence_) reset();" in sv_gate
and "check_no_intermitttent_local_at_start()" in sv_tests
and "check_warm_phase_preflight_cannot_fake_canonical_mac()" in sv_tests
and "check_warm_preflight_negative_session_and_clock_faults()" in sv_tests,
"LOCAL REF: ten LIVE epochs for COLD, ten stable phase-only PREFLIGHT epochs "
"plus three verified LIVE MAC epochs for WARM; invalidate on loss of evidence")
"RECEIVER conservative warm/cold gates and SOURCE_FAST measured two-epoch "
"phase/MAC gate must coexist; revoke on evidence loss")
phase_header = (project_root / "include/ariec61850/time_sync/sv_ptp_phase_authority.hpp").read_text(encoding="utf-8")
phase_tests = (project_root / "tests/test_sv_ptp_phase_authority.cpp").read_text(encoding="utf-8")
require("g_preflight_phase_clock_active" in firmware_source
Expand Down
8 changes: 6 additions & 2 deletions embedded/esp32p4_smv_injector/main/app_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -308,8 +308,12 @@ SampledValuesFrame make_runtime_frame(
asdu.sample_synchronization = 0U;

if (diagnostic_mirror || profile.include_sample_rate) {
asdu.sample_rate = static_cast<std::uint16_t>(profile.publisher_rate_hz);
asdu.sample_mode = kSampleModeSamplesPerSecond;
// Wire units follow the selected SCL profile, never FPS by assumption.
asdu.sample_rate = diagnostic_mirror || profile.wire_sample_rate == 0U
? static_cast<std::uint16_t>(profile.publisher_rate_hz)
: profile.wire_sample_rate;
asdu.sample_mode = diagnostic_mirror
? kSampleModeSamplesPerSecond : profile.wire_sample_mode;
} else {
asdu.sample_rate.reset();
asdu.sample_mode.reset();
Expand Down
14 changes: 12 additions & 2 deletions embedded/esp32p4_smv_injector/main/compiled_profile_adapter.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,16 @@ enum class BinaryRuntimeProfileStatus : std::uint8_t {
}
if ((decoded.profile_family != SvProfileFamily::iec61850_9_2 &&
decoded.profile_family != SvProfileFamily::legacy_9_2le) ||
decoded.sampling_basis != SvSampleMode::samples_per_second ||
(decoded.sampling_basis != SvSampleMode::samples_per_second &&
decoded.sampling_basis != SvSampleMode::samples_per_period) ||
decoded.no_asdu != 1U ||
decoded.frame_rate_hz != decoded.configured_sample_rate ||
!((decoded.sampling_basis == SvSampleMode::samples_per_second &&
decoded.frame_rate_hz == decoded.configured_sample_rate) ||
(decoded.sampling_basis == SvSampleMode::samples_per_period &&
(static_cast<std::uint64_t>(decoded.configured_sample_rate) * 50U ==
decoded.frame_rate_hz ||
static_cast<std::uint64_t>(decoded.configured_sample_rate) * 60U ==
decoded.frame_rate_hz))) ||
decoded.frame_rate_hz == 0U || decoded.frame_rate_hz > 65535U ||
decoded.leaf_count != 16U || decoded.payload_size_bytes != 64U ||
!decoded.asdu_options.sample_synchronized ||
Expand Down Expand Up @@ -68,6 +75,9 @@ enum class BinaryRuntimeProfileStatus : std::uint8_t {
candidate.no_asdu = decoded.no_asdu;
candidate.include_data_set = decoded.asdu_options.data_set;
candidate.include_sample_rate = decoded.asdu_options.sample_rate;
candidate.wire_sample_rate = static_cast<std::uint16_t>(decoded.configured_sample_rate);
candidate.wire_sample_mode =
decoded.sampling_basis == SvSampleMode::samples_per_period ? 0U : 1U;
std::copy_n(decoded.sv_id.begin(), decoded.sv_id_length,
candidate.sv_id.begin());
std::copy_n(decoded.data_set_reference.begin(),
Expand Down
3 changes: 3 additions & 0 deletions embedded/esp32p4_smv_injector/main/profile_control.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,9 @@ void handle_profile_command(char* arguments) noexcept {
g_staging.no_asdu = static_cast<std::uint16_t>(no_asdu);
g_staging.include_data_set = (flags & 0x1U) != 0U;
g_staging.include_sample_rate = (flags & 0x2U) != 0U;
// ASCII PROFILE SV remains samples/second, never inherits SCL mode.
g_staging.wire_sample_rate = 0U;
g_staging.wire_sample_mode = 1U;
return;
}

Expand Down
13 changes: 13 additions & 0 deletions embedded/esp32p4_smv_injector/main/runtime_profile.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,19 @@ bool runtime_profile_validate(const RuntimePublisherProfile& profile) noexcept {
!ar::iec61850::ethernet::is_valid_vlan_priority(profile.vlan_priority))) {
return false;
}
if (profile.include_sample_rate) {
const auto declared = profile.wire_sample_rate == 0U
? profile.publisher_rate_hz
: static_cast<std::uint32_t>(profile.wire_sample_rate);
if (declared == 0U || declared > 65535U ||
profile.wire_sample_mode > 1U ||
(profile.wire_sample_mode == 1U && declared != profile.publisher_rate_hz) ||
(profile.wire_sample_mode == 0U &&
profile.publisher_rate_hz != declared * 50U &&
profile.publisher_rate_hz != declared * 60U)) {
return false; // Reject unit-inconsistent metadata.
}
}
if (profile.include_data_set && profile.data_set_reference[0] == '\0') {
return false;
}
Expand Down
3 changes: 3 additions & 0 deletions embedded/esp32p4_smv_injector/main/runtime_profile.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ struct RuntimePublisherProfile final {
std::uint16_t no_asdu{1U};
bool include_data_set{false};
bool include_sample_rate{false};
// Ed2.1 optional wire values (zero is legacy ASCII auto-rate).
std::uint16_t wire_sample_rate{0U};
std::uint16_t wire_sample_mode{1U};
std::array<char, 96> sv_id{};
std::array<char, 160> data_set_reference{};
// Zero for factory/default or legacy ASCII commits. A valid value is set
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,14 @@ namespace detail {
[[nodiscard]] inline bool esp32p4_4i4v_layout_matches(
const SvPublisherProfile& profile) {
const auto frame_rate_hz = profile.timing.exact_frame_rate_hz();
if (profile.timing.sampling_basis != SvSampleMode::samples_per_second ||
const bool supported_basis =
profile.timing.sampling_basis == SvSampleMode::samples_per_second ||
(profile.timing.sampling_basis == SvSampleMode::samples_per_period &&
profile.timing.configured_sample_rate != 0U &&
frame_rate_hz.has_value() &&
(*frame_rate_hz == profile.timing.configured_sample_rate * 50ULL ||
*frame_rate_hz == profile.timing.configured_sample_rate * 60ULL));
if (!supported_basis ||
profile.timing.asdus_per_frame != 1U ||
!legacy_9_2le_4i4v_layout_matches(profile) ||
!frame_rate_hz.has_value() || *frame_rate_hz == 0U ||
Expand Down
44 changes: 44 additions & 0 deletions tests/test_sampled_values.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,50 @@ void compiled_binary_profile_activates_only_supported_4i4v_device_layout() {
CHECK(runtime.app_id == 0x4001U);
CHECK(runtime.sv_id[0] == 'S' && runtime.sv_id[2] == '2');
CHECK(runtime.include_data_set && runtime.include_sample_rate);
CHECK(runtime.wire_sample_rate == 4800U);
CHECK(runtime.wire_sample_mode == 1U);
for (const auto nominal_hz : {50U, 60U}) {
auto per_period = device;
per_period.sampling_basis = SvSampleMode::samples_per_period;
per_period.configured_sample_rate = 80U;
per_period.frame_rate_hz = 80U * nominal_hz;
per_period.sample_counter_modulus = static_cast<std::uint16_t>(80U * nominal_hz);
const auto bytes_needed = SvDeviceProfileBinaryCodec::encoded_size(per_period);
CHECK(bytes_needed.has_value());
std::vector<std::uint8_t> rate_bytes(*bytes_needed);
CHECK(SvDeviceProfileBinaryCodec::encode_into(per_period, rate_bytes).success());
RuntimePublisherProfile period_runtime{};
CHECK(decode_binary_runtime_profile(rate_bytes, period_runtime) ==
BinaryRuntimeProfileStatus::ok);
CHECK(period_runtime.wire_sample_rate == 80U);
CHECK(period_runtime.wire_sample_mode == 0U);
CHECK(period_runtime.publisher_rate_hz == 80U * nominal_hz);
SampledValueAsdu asdu;
asdu.sv_id = "ARSTACK_SV01";
asdu.sample_rate = period_runtime.wire_sample_rate;
asdu.sample_mode = period_runtime.wire_sample_mode;
asdu.sample_payload.resize(64U);
const auto wire = SampledValuesPduCodec::encode(SampledValuesPdu{{asdu}});
SampledValuesPdu decoded_wire{};
CHECK(SampledValuesPduCodec::try_decode(wire, decoded_wire));
CHECK(decoded_wire.asdus[0].sample_rate == 80U);
CHECK(decoded_wire.asdus[0].sample_mode == 0U);
per_period.frame_rate_hz += 10U;
CHECK(SvDeviceProfileBinaryCodec::encode_into(per_period, rate_bytes).success());
CHECK(decode_binary_runtime_profile(rate_bytes, period_runtime) ==
BinaryRuntimeProfileStatus::unsupported_device_capability);
}
auto legacy = device;
legacy.profile_family = SvProfileFamily::legacy_9_2le;
legacy.asdu_options.sample_rate = false;
const auto legacy_len = SvDeviceProfileBinaryCodec::encoded_size(legacy);
CHECK(legacy_len.has_value());
std::vector<std::uint8_t> legacy_bytes(*legacy_len);
CHECK(SvDeviceProfileBinaryCodec::encode_into(legacy, legacy_bytes).success());
RuntimePublisherProfile legacy_runtime{};
CHECK(decode_binary_runtime_profile(legacy_bytes, legacy_runtime) ==
BinaryRuntimeProfileStatus::ok);
CHECK(!legacy_runtime.include_sample_rate);
CHECK(runtime.data_set_reference[0] == 'D');
const auto previous_generation = runtime.generation;
chunks.abort();
Expand Down
Loading