Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f6c2505
feat(studio): declare canonical SV VLAN override for each SCL stream
masarray Oct 22, 2026
4ce1001
feat(studio): per-stream VLAN editing survives SCL recompilation and …
masarray Oct 22, 2026
8897bef
feat(studio): edit SV Ethernet VLAN PCP/VID per SCL stream and requir…
masarray Oct 22, 2026
4f19e63
feat(studio): expose canonical SV profile to PTP Expert
masarray Oct 22, 2026
f8343fa
feat(studio): independently set PTP VLAN or explicitly match SV VLAN …
masarray Oct 22, 2026
f9c6692
fix(studio): verify SV VLAN on canonical binary device profile before…
masarray Oct 22, 2026
abf80d3
feat(ptp): typed isolated bench GM epoch and quality profile configur…
masarray Oct 22, 2026
11b846a
feat(esp32p4): isolated GNSS-like bench GM wire qualities gated on EM…
masarray Oct 22, 2026
c7e9e4f
feat(ptp): monotonic compensated Studio UTC seed for emulated PTP/TAI…
masarray Oct 22, 2026
6fc757c
feat(firmware): transactional PTP CONFIG accepts explicit bench GM mo…
masarray Oct 22, 2026
2000194
feat(sv): promote smpSynch 2 only with measured SV MAC lock and fresh…
masarray Oct 22, 2026
9582291
feat(firmware): expose bench GM mode capability to native Studio
masarray Oct 22, 2026
2f5ac67
feat(studio): explicit PTP Grandmaster simulation option for relay in…
masarray Oct 22, 2026
cb92851
feat(studio): send fresh UTC-epoch BENCH GM PTP CONFIG to supporting …
masarray Oct 22, 2026
c267260
feat(sv): model optional IEC 61850 Ed2.1 gmIdentity synchronization s…
masarray Oct 22, 2026
791636b
feat(sv): encode/decode standard context tag 0x89 eight-byte PTP gmId…
masarray Oct 22, 2026
3371f19
feat(esp32p4): include matching PTP GrandmasterIdentity in SV Ed2.1 b…
masarray Oct 22, 2026
dcb0402
test(sv): Ed2.1 gmIdentity wire tag 0x89, roundtrip and malformed length
masarray Oct 22, 2026
6baa66c
test(studio): native canonical SV VLAN edit, binary profile, invalid …
masarray Oct 22, 2026
63ea71e
fix(esp32): include portable PTP EUI-64 clock identity helper header
masarray Oct 22, 2026
ce79ef0
fix(test): initialize optional gmIdentity in existing strict aggregat…
masarray Oct 22, 2026
530d531
fix(studio): reload VLAN editor on active SCL stream change; avoid cr…
masarray Oct 22, 2026
a1cf936
fix(ptp): validate UTC seed freshness at source START, not role handoff
masarray Oct 22, 2026
95e8c76
test(ptp): GNSS-profile simulator Announce wire flags, class, accurac…
masarray Oct 22, 2026
d56f0f6
fix(studio): include span for native VLAN binary regression
masarray Oct 22, 2026
94b8c14
fix(studio): gate advanced BENCH GM configuration on verified compati…
masarray Oct 22, 2026
60165e6
fix(sv): preserve aggregate source compatibility by appending gmIdent…
masarray Oct 22, 2026
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
1 change: 1 addition & 0 deletions apps/arstack_studio/qml/ConfigurationHub.qml
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ Item {
theme: hub.theme
controller: hub.controller
device: hub.device
profiles: hub.profiles
session: hub.session
uiFont: hub.uiFont
monoFont: hub.monoFont
Expand Down
85 changes: 85 additions & 0 deletions apps/arstack_studio/qml/ProfileInspector.qml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ SurfacePanel {
panel.controller.openEngineeringFile()
}

// Switching SCL streams or recompiling the selected one must reload the
// editor from that stream. Never silently apply another stream's VLAN.
Connections {
target: panel.profiles
function onSelectedProfileChanged() {
svVlanEnabled.checked = Boolean(panel.profile.vlanPresent)
svVlanVid.text = String(panel.profile.vlanId === undefined ? 0 : panel.profile.vlanId)
svVlanPcp.text = String(panel.profile.vlanPriority === undefined ? 4 : panel.profile.vlanPriority)
}
}

ColumnLayout {
anchors.fill: parent
anchors.margins: panel.compact ? 11 : 14
Expand Down Expand Up @@ -261,6 +272,80 @@ SurfacePanel {
}
}

// Applies to the canonical SV binary profile, not just the UI.
// A fresh profile deployment is required before SV START.
ColumnLayout {
Layout.fillWidth: true
spacing: 5
Label {
text: "SV VLAN · Ethernet output"
color: panel.theme.text
font.family: panel.uiFont
font.pixelSize: panel.theme.captionSize
font.weight: Font.DemiBold
}
RowLayout {
Layout.fillWidth: true
spacing: 5
StudioDarkCheckBox {
id: svVlanEnabled
theme: panel.theme
uiFont: panel.uiFont
text: "802.1Q"
checked: Boolean(panel.profile.vlanPresent)
enabled: panel.session && panel.session.engineeringEditable
}
Label { text: "VID"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 10 }
NumericField {
id: svVlanVid
Layout.preferredWidth: 66
compact: true
theme: panel.theme
monoFont: panel.monoFont
text: String(panel.profile.vlanId === undefined ? 0 : panel.profile.vlanId)
validator: IntValidator { bottom: 0; top: 4094 }
enabled: svVlanEnabled.checked && panel.session && panel.session.engineeringEditable
}
Label { text: "PCP"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 10 }
NumericField {
id: svVlanPcp
Layout.preferredWidth: 45
compact: true
theme: panel.theme
monoFont: panel.monoFont
text: String(panel.profile.vlanPriority === undefined ? 4 : panel.profile.vlanPriority)
validator: IntValidator { bottom: 0; top: 7 }
enabled: svVlanEnabled.checked && panel.session && panel.session.engineeringEditable
}
CalmButton {
theme: panel.theme
uiFont: panel.uiFont
text: "Apply"
enabled: panel.session && panel.session.engineeringEditable
onClicked: {
if (!svVlanVid.acceptableInput || !svVlanPcp.acceptableInput
|| svVlanVid.text.length === 0 || svVlanPcp.text.length === 0
|| !panel.profiles.configureSelectedVlan(
svVlanEnabled.checked, Number(svVlanVid.text),
Number(svVlanPcp.text))) {
panel.controller.showMessage("SV VLAN must have VID 0-4094 and PCP 0-7.", true)
return
}
panel.controller.profileDirty = true
panel.controller.showMessage("SV VLAN updated in canonical profile. Deploy before START.", false)
}
}
}
Label {
Layout.fillWidth: true
text: "VID 0 = priority-tagged (PCP only). A change requires Deploy Profile."
color: panel.theme.muted
font.family: panel.uiFont
font.pixelSize: panel.theme.captionSize
wrapMode: Text.WordWrap
}
}

ColumnLayout {
visible: panel.issueItems.length > 0
Layout.fillWidth: true
Expand Down
39 changes: 38 additions & 1 deletion apps/arstack_studio/qml/PtpExpertPanel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ SurfacePanel {
property var controller
property var device
property var session
property var profiles
property string uiFont: "Inter"
property string monoFont: "Inter"
property var roleValues: ["SOURCE", "RECEIVER", "MONITOR"]
Expand Down Expand Up @@ -42,7 +43,8 @@ SurfacePanel {
syncIntervalMs: role === "SOURCE" ? 1000 : Number(syncField.text),
// IEC/IEEE 61850-9-3 SOURCE always requires Pdelay capability.
// An optional bench-only mode must never be silently substituted.
respondToPeerDelay: role === "SOURCE" ? true : pdelayCheck.checked
respondToPeerDelay: role === "SOURCE" ? true : pdelayCheck.checked,
benchGlobalGm: role === "SOURCE" && benchGmCheck.checked
}
}

Expand All @@ -51,6 +53,16 @@ SurfacePanel {
panel.ptpProfile(panel.activeRole))
}

function matchSvVlan() {
if (!panel.profiles || !panel.profiles.hasProfiles)
return false
const sv = panel.profiles.selectedProfile
vlanCheck.checked = Boolean(sv.vlanPresent)
vlanField.text = String(sv.vlanId)
pcpField.text = String(sv.vlanPriority)
return true
}

function startRole(role) {
if (!panel.session || !panel.device.ptpAvailable || !panel.device.deviceVerified
|| panel.device.ptpRunning || panel.startPending || panel.session.updatingFirmware)
Expand Down Expand Up @@ -274,6 +286,21 @@ SurfacePanel {
}
}

StudioDarkCheckBox {
id: benchGmCheck
Layout.fillWidth: true
visible: modeCombo.currentIndex === 0
theme: panel.theme
uiFont: panel.uiFont
checked: true
text: "Relay test bench · Simulated GNSS PTP Grandmaster + measured SV phase lock"
enabled: panel.session && panel.device.ptpAvailable &&
!panel.device.ptpRunning && !panel.startPending &&
!panel.session.updatingFirmware
ToolTip.visible: hovered
ToolTip.text: "Isolated relay test network only. No GPS required; PTP and SV share ESP32-P4 EMAC timebase. smpSynch=2 only after real phase/MAC evidence."
}

// Simulation is intentionally simple: auto or explicit SV flag.
// The firmware already marks overrides as simulated, independently
// of PTP clock discipline and without pretending sample phase lock.
Expand Down Expand Up @@ -549,6 +576,16 @@ SurfacePanel {
NumericField { id: vlanField; enabled: vlanCheck.checked; theme: panel.theme; monoFont: panel.uiFont; text: "0"; validator: IntValidator { bottom: 0; top: 4094 } }
Label { text: "VLAN PCP"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 }
NumericField { id: pcpField; enabled: vlanCheck.checked; theme: panel.theme; monoFont: panel.uiFont; text: "4"; validator: IntValidator { bottom: 0; top: 7 } }
CalmButton {
theme: panel.theme
uiFont: panel.uiFont
text: "Match SV VLAN"
enabled: panel.profiles && panel.profiles.hasProfiles &&
!panel.device.ptpRunning && !panel.startPending
onClicked: panel.matchSvVlan()
ToolTip.visible: hovered
ToolTip.text: "Copy current SV profile VLAN enable, VID and PCP into PTP settings"
}
Label {
visible: modeCombo.currentIndex === 0
Layout.columnSpan: 4
Expand Down
25 changes: 24 additions & 1 deletion apps/arstack_studio/src/DeviceController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,9 @@ bool DeviceController::deployProfile(const QVariantMap& profile) {
if (svId.isEmpty() ||
svId != profile.value(QStringLiteral("svId")).toString() ||
decoded.app_id != profile.value(QStringLiteral("appId")).toUInt() ||
decoded.vlan_present != profile.value(QStringLiteral("vlanPresent")).toBool() ||
decoded.vlan_id != profile.value(QStringLiteral("vlanId")).toUInt() ||
decoded.vlan_priority != profile.value(QStringLiteral("vlanPriority")).toUInt() ||
decoded.frame_rate_hz != profile.value(QStringLiteral("publisherRate")).toULongLong() ||
decoded.sample_counter_modulus != profile.value(QStringLiteral("counterModulus")).toUInt() ||
decoded.configuration_revision != profile.value(QStringLiteral("confRev")).toULongLong()) {
Expand Down Expand Up @@ -1353,11 +1356,17 @@ QString DeviceController::ptpConfigCommand(const QVariantMap& profile) {
announce < 100U || sync < 20U) {
return {};
}
return QStringLiteral("PTP CONFIG %1 %2 %3 %4 %5 %6 %7 %8")
QString command = QStringLiteral("PTP CONFIG %1 %2 %3 %4 %5 %6 %7 %8")
.arg(domain).arg(transport)
.arg(profile.value(QStringLiteral("vlanEnabled")).toBool() ? 1 : 0)
.arg(vlan).arg(pcp).arg(announce).arg(sync)
.arg(profile.value(QStringLiteral("respondToPeerDelay")).toBool() ? 1 : 0);
if (profile.value(QStringLiteral("benchGlobalGm")).toBool()) {
// Studio supplies a fresh host UTC epoch. The device then uses
// one EMAC IEEE1588 timebase for both PTP TX and SV sampling.
command += QStringLiteral(" 1 %1").arg(QDateTime::currentSecsSinceEpoch());

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 Preserve subsecond time when seeding the simulated GM

When bench GM mode is enabled, currentSecsSinceEpoch() truncates the host timestamp to a whole second before the command is queued and transmitted. The firmware then treats command receipt as that second boundary and reconstructs nanoseconds only from subsequent board-monotonic elapsed time, so the advertised PTP clock can be offset from UTC by nearly one second plus transport latency while declaring within_1_us accuracy and time traceability. Send a seconds-plus-subseconds timestamp with a correlated monotonic sample, or downgrade the advertised accuracy/traceability.

AGENTS.md reference: AGENTS.md:L172-L180

Useful? React with 👍 / 👎.

}
return command;
}

void DeviceController::resetPtpStartup() noexcept {
Expand Down Expand Up @@ -1428,6 +1437,13 @@ bool DeviceController::startPtpConfigured(const QString& requestedRole,
setError(QStringLiteral("Connect a PTP-capable ESP32-P4 and stop timing before starting."));
return false;
}
if (profile.value(QStringLiteral("benchGlobalGm")).toBool() &&
(role != QStringLiteral("SOURCE") ||
!identity_.capabilities.contains(QStringLiteral("PTP-GM-BENCH-V1"),
Qt::CaseInsensitive))) {
setError(QStringLiteral("Bench GM requires SOURCE and matched PTP-GM-BENCH-V1 firmware."));
return false;
}
const QString command = ptpConfigCommand(profile);
if (command.isEmpty()) {
const QString reason = QStringLiteral(
Expand Down Expand Up @@ -1675,6 +1691,13 @@ bool DeviceController::configurePtp(const QVariantMap& profile) {
setError(QStringLiteral("Stop PTP and finish pending operations before changing settings."));
return false;
}
if (profile.value(QStringLiteral("benchGlobalGm")).toBool() &&
(ptpRole_ != QStringLiteral("SOURCE") ||
!identity_.capabilities.contains(QStringLiteral("PTP-GM-BENCH-V1"),
Qt::CaseInsensitive))) {
setError(QStringLiteral("BENCH GM requires SOURCE role and PTP-GM-BENCH-V1 firmware."));
return false;
}
const QString command = ptpConfigCommand(profile);
if (command.isEmpty()) {
setError(QStringLiteral("PTP expert profile is outside firmware safety bounds."));
Expand Down
25 changes: 25 additions & 0 deletions apps/arstack_studio/src/SclProfileModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ void SclProfileModel::installDocument(
document_->sampled_values_streams.size(),
referenceTemplate ? SvProfileFamily::legacy_9_2le
: SvProfileFamily::unspecified);
vlanOverrides_.assign(document_->sampled_values_streams.size(), std::nullopt);
rows_.clear();
selectedIndex_ = -1;
fatalError_.clear();
Expand Down Expand Up @@ -296,6 +297,7 @@ void SclProfileModel::clear() {
confirmedCounterModulus_.reset();
confirmedNominalFrequencyMilliHz_.reset();
profileFamilies_.clear();
vlanOverrides_.clear();
rows_.clear();
selectedIndex_ = -1;
fatalError_.clear();
Expand All @@ -315,6 +317,22 @@ void SclProfileModel::selectStream(const int row) {
emit selectedProfileChanged();
}

bool SclProfileModel::configureSelectedVlan(const bool present, const int vid, const int pcp) {
if (!document_.has_value() || selectedIndex_ < 0 ||
selectedIndex_ >= static_cast<int>(vlanOverrides_.size()) ||
vid < 0 || vid > 4094 || pcp < 0 || pcp > 7) return false;
const auto index = static_cast<std::size_t>(selectedIndex_);
if (index >= rows_.size() || !rows_[index].profile.has_value()) return false;
const auto& current = *rows_[index].profile;
if (current.vlan_present == present &&
current.vlan_id == static_cast<std::uint16_t>(vid) &&
current.vlan_priority == static_cast<std::uint8_t>(pcp)) return true;
vlanOverrides_[index] = VlanOverride{
present, static_cast<std::uint16_t>(vid), static_cast<std::uint8_t>(pcp)};
rebuildRows();
return true;
}

bool SclProfileModel::confirmCounterModulus(const int modulus) {
if (!document_.has_value() || modulus <= 0 || modulus > 65535) return false;
confirmedCounterModulus_ = static_cast<std::uint16_t>(modulus);
Expand Down Expand Up @@ -403,6 +421,13 @@ void SclProfileModel::rebuildRows() {
row.deviceSupport = QStringLiteral("blocked");
} else {
row.profile = std::move(compiled.profile);
if (streamIndex < vlanOverrides_.size() &&
vlanOverrides_[streamIndex].has_value()) {
const auto& override = *vlanOverrides_[streamIndex];
row.profile->vlan_present = override.present;
row.profile->vlan_id = override.vid;
row.profile->vlan_priority = override.pcp;
}
const auto& profile = *row.profile;
row.compatibilityClass =
profile.profile_family_resolution == SvProfileFamilyResolution::resolved &&
Expand Down
9 changes: 9 additions & 0 deletions apps/arstack_studio/src/SclProfileModel.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ class SclProfileModel : public QAbstractListModel {
Q_INVOKABLE bool confirmNominalFrequencyHz(int frequencyHz);
Q_INVOKABLE void clearNominalFrequency();
Q_INVOKABLE bool selectProfileFamily(const QString& familyKey);
// Operator L2 override is compiled into the SAME canonical device profile
// and binary transaction. It is never a display-only VLAN setting.
Q_INVOKABLE bool configureSelectedVlan(bool present, int vid, int pcp);

signals:
void sourceChanged();
Expand All @@ -85,6 +88,11 @@ class SclProfileModel : public QAbstractListModel {
};

void installDocument(ar::iec61850::scl::SclDocument document, bool referenceTemplate);
struct VlanOverride final {
bool present{};
std::uint16_t vid{};
std::uint8_t pcp{};
};
void rebuildRows();
[[nodiscard]] QVariantMap profileToVariantMap(
const ar::iec61850::sampled_values::SvPublisherProfile& profile) const;
Expand All @@ -93,6 +101,7 @@ class SclProfileModel : public QAbstractListModel {
std::optional<std::uint16_t> confirmedCounterModulus_;
std::optional<std::uint32_t> confirmedNominalFrequencyMilliHz_;
std::vector<ar::iec61850::sampled_values::SvProfileFamily> profileFamilies_;
std::vector<std::optional<VlanOverride>> vlanOverrides_;
std::vector<Row> rows_;
int selectedIndex_{-1};
QString fatalError_;
Expand Down
34 changes: 33 additions & 1 deletion apps/arstack_studio/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include "StudioClipboard.hpp"
#include "StudioDeviceController.hpp"
#include "SvCaptureController.hpp"
#include "ariec61850/sampled_values/compiled_device_profile.hpp"

#include <QClipboard>
#include <QCloseEvent>
Expand All @@ -34,6 +35,7 @@
#include <array>
#include <cmath>
#include <memory>
#include <span>
#include <string_view>

#ifndef ARSTACK_STUDIO_VERSION
Expand Down Expand Up @@ -199,8 +201,38 @@ int checkReferenceTemplate(int argc, char* argv[]) {
return 3;
}

// An operator VLAN edit MUST change the binary sent to the ESP32, not
// only a QML label. It must survive an SCL timing recompile and reject
// invalid values without corrupting the active stream profile.
if (!profiles.configureSelectedVlan(true, 123, 5) ||
profiles.configureSelectedVlan(true, 4095, 5) ||
profiles.configureSelectedVlan(true, 123, 8) ||
!profiles.confirmCounterModulus(4000)) {
qCritical().noquote() << "SV VLAN operator edit/recompile regression";
return 4;
}
const auto edited = profiles.selectedProfile();
const auto blob = edited.value(QStringLiteral("deviceProfileBinary")).toByteArray();
ar::iec61850::sampled_values::CompiledSvDeviceProfile decoded{};
const auto bytes = std::span<const std::uint8_t>{
reinterpret_cast<const std::uint8_t*>(blob.constData()),
static_cast<std::size_t>(blob.size())};
if (blob.isEmpty() ||
!ar::iec61850::sampled_values::SvDeviceProfileBinaryCodec::decode(bytes, decoded).success() ||
!decoded.vlan_present || decoded.vlan_id != 123U || decoded.vlan_priority != 5U ||
edited.value(QStringLiteral("vlanId")).toInt() != 123 ||
edited.value(QStringLiteral("vlanPriority")).toInt() != 5) {
qCritical().noquote() << "SV VLAN binary profile mismatch" << edited;
return 5;
}
if (!profiles.loadReferenceTemplate() ||
profiles.selectedProfile().value(QStringLiteral("vlanId")).toInt() != 0 ||
profiles.selectedProfile().value(QStringLiteral("vlanPriority")).toInt() != 4) {
qCritical().noquote() << "SV VLAN override leaked across reference reload";
return 6;
}
qInfo().noquote()
<< "ARStack 4I+4V 9-2LE reference template: PASS · Class A · ready · 4000 fps · 64 B";
<< "ARStack 4I+4V 9-2LE reference: PASS · 4000 fps · SV VLAN edit/binary/reload";
return 0;
}

Expand Down
Loading
Loading