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
26 changes: 23 additions & 3 deletions .github/workflows/smv-injector-gui.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
- 'apps/smv_injector_gui/**'
- 'tools/smv_profile_inspect.cpp'
- 'include/ariec61850/sampled_values/publisher_profile.hpp'
- 'include/ariec61850/sampled_values/profile_family.hpp'
- 'include/ariec61850/scl/**'
- 'src/scl/**'
- 'tests/fixtures/scl/**'
Expand All @@ -15,6 +16,7 @@ on:
- 'apps/smv_injector_gui/**'
- 'tools/smv_profile_inspect.cpp'
- 'include/ariec61850/sampled_values/publisher_profile.hpp'
- 'include/ariec61850/sampled_values/profile_family.hpp'
- 'include/ariec61850/scl/**'
- 'src/scl/**'
- 'tests/fixtures/scl/**'
Expand Down Expand Up @@ -67,10 +69,28 @@ jobs:
fixture=tests/fixtures/scl/sv-4800-structured-4i4v.scd
build-smv-gui/ariec61850_smv_profile_inspect "$fixture" > /tmp/profile-b.json
grep -q '"compatibilityClass":"B"' /tmp/profile-b.json
grep -q '"deviceSupport":"needs-profile-family-confirmation"' /tmp/profile-b.json
grep -q '"publisherRateHz":4800' /tmp/profile-b.json
build-smv-gui/ariec61850_smv_profile_inspect "$fixture" --counter-modulus 4800 > /tmp/profile-a.json
build-smv-gui/ariec61850_smv_profile_inspect "$fixture" --profile-family iec61850-9-2 --counter-modulus 4800 > /tmp/profile-a.json
grep -q '"compatibilityClass":"A"' /tmp/profile-a.json
grep -q '"profileFamily":"iec61850-9-2"' /tmp/profile-a.json
grep -q '"deviceSupport":"ready"' /tmp/profile-a.json
build-smv-gui/ariec61850_smv_profile_inspect "$fixture" --profile-family iec61869-9 --counter-modulus 4800 > /tmp/profile-61869.json
grep -q '"compatibilityClass":"B"' /tmp/profile-61869.json
grep -q '"profileFamilyResolution":"incomplete"' /tmp/profile-61869.json
grep -q '"deviceSupport":"unsupported-profile-family"' /tmp/profile-61869.json

multi=tests/fixtures/scl/compact-multi-stream.scd
build-smv-gui/ariec61850_smv_profile_inspect "$multi" --stream-profile-family 0:iec61850-9-2 --counter-modulus 4000 > /tmp/profile-multi.json
python3 - <<'PY'
import json
data = json.load(open('/tmp/profile-multi.json', encoding='utf-8'))
assert len(data['streams']) >= 2
assert data['streams'][0]['profile']['profileFamily'] == 'iec61850-9-2'
for stream in data['streams'][1:]:
assert stream['profile']['profileFamily'] == 'unspecified'
assert stream['compatibilityClass'] == 'B'
PY

- name: Local backend smoke
shell: bash
Expand All @@ -86,7 +106,7 @@ jobs:
curl --fail --silent -X POST \
-H 'X-File-Name: fixture.scd' \
--data-binary @tests/fixtures/scl/sv-4800-structured-4i4v.scd \
'http://127.0.0.1:8765/api/scl/inspect?counterModulus=4800' >/tmp/api.json
'http://127.0.0.1:8765/api/scl/inspect?counterModulus=4800&profileFamilies=0:iec61850-9-2' >/tmp/api.json
grep -q '"deviceSupport":"ready"' /tmp/api.json

windows-launcher-smoke:
Expand All @@ -108,7 +128,7 @@ jobs:
run: |
$tool = Join-Path $PWD 'build-smv-gui\ariec61850_smv_profile_inspect.exe'
if (-not (Test-Path $tool)) { throw "profile compiler missing: $tool" }
& $tool 'tests\fixtures\scl\sv-4800-structured-4i4v.scd' '--counter-modulus' '4800' | Tee-Object -FilePath profile-a.json
& $tool 'tests\fixtures\scl\sv-4800-structured-4i4v.scd' '--profile-family' 'iec61850-9-2' '--counter-modulus' '4800' | Tee-Object -FilePath profile-a.json
if (-not (Select-String -Path profile-a.json -SimpleMatch '"deviceSupport":"ready"')) {
throw 'Windows profile compiler did not produce a deployable Class-A fixture result'
}
8 changes: 5 additions & 3 deletions NORTH_STAR.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,14 @@ ARStack must track standards explicitly rather than use “IEC 61850” as a gen
| Redundancy | IEC 62439-3 | PRP/HSR observation, publication and validation roadmap |
| IEC 61850 security | IEC 62351-6 | Security-aware future protocol/tooling boundary |

Profile labels are not interchangeable:
Profile labels are not interchangeable, are resolved per stream, and are never inferred from vendor, filename, `svID`, or DataSet shape:

1. **Generic IEC 61850-9-2 / SCL-driven** — exact selected SCL stream drives the output.
2. **IEC 61869-9** — only when its constraints are explicitly implemented and evidenced.
1. **Generic IEC 61850-9-2 / SCL-driven** — exact selected SCL stream and its multicast/unicast engineering intent drive the output.
2. **IEC 61869-9** — representable as an explicit family, but deployable only when its scaling/variant constraints are explicitly implemented and evidenced.
3. **Legacy 9-2LE compatibility** — an explicit compatibility mode, never silently promoted to the generic standard.

SV link identity is also gated independently: APPID belongs to the SV allocation and multicast publication must use the IEC Sampled Values multicast MAC block. Device capability remains a separate gate from standards/profile validity.

## 5. Core architecture principles

### 5.1 Separate facts, expectations, claims and synchronization
Expand Down
57 changes: 57 additions & 0 deletions apps/arstack_studio/qml/ProfileInspector.qml
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,15 @@ SurfacePanel {
Label { Layout.fillWidth: true; text: panel.profile.dataSetReference || "—"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: panel.theme.captionSize; elide: Text.ElideMiddle }
Label { text: "MAC / APPID"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize }
Label { Layout.fillWidth: true; text: (panel.profile.destinationMac || "—") + " · " + (panel.profile.appIdHex || "—"); color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: panel.theme.captionSize; elide: Text.ElideRight }
Label { text: "Profile"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize }
Label {
Layout.fillWidth: true
text: (panel.profile.profileFamily || "unspecified") + " · " + (panel.profile.transportMode || "—")
color: panel.profile.profileFamilyResolution === "resolved" ? panel.theme.textSoft : panel.theme.amber
font.family: panel.monoFont
font.pixelSize: panel.theme.captionSize
elide: Text.ElideRight
}
Label { text: "Sampling"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize }
Label {
text: panel.profile.sampleRate
Expand Down Expand Up @@ -250,6 +259,54 @@ SurfacePanel {
font.letterSpacing: 0.9
}

ColumnLayout {
visible: !panel.profiles.referenceTemplateActive
Layout.fillWidth: true
spacing: 5

Label {
Layout.fillWidth: true
text: "Profile family is explicit. ARStack never infers it from vendor, filename, svID, or DataSet shape."
color: panel.theme.muted
font.family: panel.uiFont
font.pixelSize: panel.theme.captionSize
wrapMode: Text.WordWrap
}

ComboBox {
id: profileFamilySelector
Layout.fillWidth: true
enabled: panel.session && panel.session.engineeringEditable
model: ["Select family…", "IEC 61850-9-2", "Legacy 9-2LE", "IEC 61869-9"]
currentIndex: panel.profile.profileFamily === "iec61850-9-2" ? 1
: panel.profile.profileFamily === "9-2le" ? 2
: panel.profile.profileFamily === "iec61869-9" ? 3 : 0
font.family: panel.uiFont
font.pixelSize: panel.theme.labelSize
onActivated: {
var keys = ["unspecified", "iec61850-9-2", "9-2le", "iec61869-9"]
if (panel.profiles.selectProfileFamily(keys[currentIndex])) {
panel.controller.profileDirty = true
panel.controller.showMessage("SV profile family updated.", false)
}
}
}
}

ColumnLayout {
visible: panel.profile.profileFamilyResolution === "incomplete"
Layout.fillWidth: true
spacing: 5
Label {
Layout.fillWidth: true
text: "IEC 61869-9 transport/address semantics are represented, but scaling and variant rules are not complete yet. Device deployment remains blocked."
color: panel.theme.amber
font.family: panel.uiFont
font.pixelSize: panel.theme.captionSize
wrapMode: Text.WordWrap
}
}

ColumnLayout {
visible: panel.profile.timingResolved === false
&& panel.profile.sampleMode === "SmpPerPeriod"
Expand Down
66 changes: 65 additions & 1 deletion apps/arstack_studio/src/SclProfileModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,15 @@ using ar::iec61850::sampled_values::Esp32P4SvProfileSupport;
using ar::iec61850::sampled_values::SvPublisherProfile;
using ar::iec61850::sampled_values::SvPublisherProfileCompileContext;
using ar::iec61850::sampled_values::SvPublisherProfileCompiler;
using ar::iec61850::sampled_values::SvProfileFamily;
using ar::iec61850::sampled_values::SvProfileFamilyResolution;
using ar::iec61850::sampled_values::SvSampleCounterPolicy;
using ar::iec61850::sampled_values::SvSampleMode;
using ar::iec61850::sampled_values::classify_esp32p4_sv_profile;
using ar::iec61850::sampled_values::esp32p4_sv_profile_support_name;
using ar::iec61850::sampled_values::sv_profile_family_name;
using ar::iec61850::sampled_values::sv_profile_family_resolution_name;
using ar::iec61850::sampled_values::sv_transport_mode_name;

constexpr std::uint16_t kReferenceCounterModulus = 4000;
constexpr auto kReferenceResource = ":/arstack/templates/arstack_4i4v_9-2le_reference.scd";
Expand Down Expand Up @@ -59,6 +64,16 @@ QString sampleModeText(const SvSampleMode mode) {
}
}

QString profileFamilyText(const SvProfileFamily family) {
const auto value = sv_profile_family_name(family);
return QString::fromLatin1(value.data(), static_cast<qsizetype>(value.size()));
}

QString profileFamilyResolutionText(const SvProfileFamilyResolution resolution) {
const auto value = sv_profile_family_resolution_name(resolution);
return QString::fromLatin1(value.data(), static_cast<qsizetype>(value.size()));
}

QString counterPolicyText(const SvSampleCounterPolicy policy) {
switch (policy) {
case SvSampleCounterPolicy::explicit_modulus: return QStringLiteral("explicit");
Expand Down Expand Up @@ -193,6 +208,10 @@ void SclProfileModel::installDocument(
? std::optional<std::uint16_t>{kReferenceCounterModulus}
: std::nullopt;
confirmedNominalFrequencyMilliHz_.reset();
profileFamilies_.assign(
document_->sampled_values_streams.size(),
referenceTemplate ? SvProfileFamily::legacy_9_2le
: SvProfileFamily::unspecified);
rows_.clear();
selectedIndex_ = -1;
fatalError_.clear();
Expand Down Expand Up @@ -254,6 +273,7 @@ void SclProfileModel::clear() {
document_.reset();
confirmedCounterModulus_.reset();
confirmedNominalFrequencyMilliHz_.reset();
profileFamilies_.clear();
rows_.clear();
selectedIndex_ = -1;
fatalError_.clear();
Expand Down Expand Up @@ -299,6 +319,37 @@ void SclProfileModel::clearNominalFrequency() {
rebuildRows();
}

bool SclProfileModel::selectProfileFamily(const QString& familyKey) {
if (!document_.has_value() || selectedIndex_ < 0 ||
selectedIndex_ >= static_cast<int>(profileFamilies_.size())) {
return false;
}
const auto key = familyKey.trimmed().toLower();
SvProfileFamily selected = SvProfileFamily::unspecified;
if (key.isEmpty() || key == QStringLiteral("unspecified")) {
selected = SvProfileFamily::unspecified;
} else if (key == QStringLiteral("iec61850-9-2")) {
selected = SvProfileFamily::iec61850_9_2;
} else if (key == QStringLiteral("9-2le")) {
selected = SvProfileFamily::legacy_9_2le;
} else if (key == QStringLiteral("iec61869-9")) {
selected = SvProfileFamily::iec61869_9;
} else {
return false;
}

// The built-in reference is an explicitly named 9-2LE compatibility
// artifact, not a family-detection heuristic.
if (referenceTemplateActive_ && selected != SvProfileFamily::legacy_9_2le) {
return false;
}
auto& current = profileFamilies_[static_cast<std::size_t>(selectedIndex_)];
if (current == selected) return true;
current = selected;
rebuildRows();
return true;
}

void SclProfileModel::rebuildRows() {
if (!document_.has_value()) return;

Expand All @@ -307,8 +358,13 @@ void SclProfileModel::rebuildRows() {
rows_.clear();
rows_.reserve(document_->sampled_values_streams.size());

for (const auto& stream : document_->sampled_values_streams) {
for (std::size_t streamIndex = 0U;
streamIndex < document_->sampled_values_streams.size(); ++streamIndex) {
const auto& stream = document_->sampled_values_streams[streamIndex];
SvPublisherProfileCompileContext context;
context.profile_family = streamIndex < profileFamilies_.size()
? profileFamilies_[streamIndex]
: SvProfileFamily::unspecified;
context.sample_counter_modulus = confirmedCounterModulus_;
context.nominal_frequency_millihz = confirmedNominalFrequencyMilliHz_;
auto compiled = SvPublisherProfileCompiler::compile(stream, context);
Expand All @@ -327,6 +383,7 @@ void SclProfileModel::rebuildRows() {
row.profile = std::move(compiled.profile);
const auto& profile = *row.profile;
row.compatibilityClass =
profile.profile_family_resolution == SvProfileFamilyResolution::resolved &&
profile.timing.resolved() &&
profile.sample_counter_policy == SvSampleCounterPolicy::explicit_modulus
? QStringLiteral("A")
Expand Down Expand Up @@ -356,6 +413,13 @@ QVariantMap SclProfileModel::profileToVariantMap(const SvPublisherProfile& p) co
map.insert(QStringLiteral("appId"), static_cast<int>(p.app_id));
map.insert(QStringLiteral("appIdHex"),
QStringLiteral("0x%1").arg(p.app_id, 4, 16, QLatin1Char('0')).toUpper());
map.insert(QStringLiteral("profileFamily"), profileFamilyText(p.profile_family));
map.insert(QStringLiteral("profileFamilyResolution"),
profileFamilyResolutionText(p.profile_family_resolution));
const auto transportMode = sv_transport_mode_name(p.transport_mode);
map.insert(QStringLiteral("transportMode"),
QString::fromLatin1(
transportMode.data(), static_cast<qsizetype>(transportMode.size())));
map.insert(QStringLiteral("vlanPresent"), p.vlan_present);
map.insert(QStringLiteral("vlanId"), static_cast<int>(p.vlan_id));
map.insert(QStringLiteral("vlanPriority"), static_cast<int>(p.vlan_priority));
Expand Down
2 changes: 2 additions & 0 deletions apps/arstack_studio/src/SclProfileModel.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ class SclProfileModel : public QAbstractListModel {
Q_INVOKABLE void clearCounterConfirmation();
Q_INVOKABLE bool confirmNominalFrequencyHz(int frequencyHz);
Q_INVOKABLE void clearNominalFrequency();
Q_INVOKABLE bool selectProfileFamily(const QString& familyKey);

signals:
void sourceChanged();
Expand Down Expand Up @@ -91,6 +92,7 @@ class SclProfileModel : public QAbstractListModel {
std::optional<ar::iec61850::scl::SclDocument> document_;
std::optional<std::uint16_t> confirmedCounterModulus_;
std::optional<std::uint32_t> confirmedNominalFrequencyMilliHz_;
std::vector<ar::iec61850::sampled_values::SvProfileFamily> profileFamilies_;
std::vector<Row> rows_;
int selectedIndex_{-1};
QString fatalError_;
Expand Down
3 changes: 2 additions & 1 deletion apps/ied_simulator/src/SclWorkspaceController_part_00.inc
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,8 @@ bool sampledValuesEquivalent(
lhs.data_set_name != rhs.data_set_name || lhs.data_set_reference != rhs.data_set_reference ||
lhs.configuration_revision != rhs.configuration_revision || !(lhs.address == rhs.address) ||
!entriesEquivalent(lhs.entries, rhs.entries) || lhs.sv_id != rhs.sv_id ||
lhs.smv_id != rhs.smv_id || lhs.sample_rate != rhs.sample_rate ||
lhs.smv_id != rhs.smv_id || lhs.multicast != rhs.multicast ||
lhs.multicast_valid != rhs.multicast_valid || lhs.sample_rate != rhs.sample_rate ||
lhs.sample_mode != rhs.sample_mode || lhs.no_asdu != rhs.no_asdu ||
!(lhs.smv_options == rhs.smv_options)) {
return false;
Expand Down
19 changes: 19 additions & 0 deletions apps/smv_injector_gui/host_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,21 @@ def do_POST(self) -> None:
body = self.rfile.read(length)
query = urllib.parse.parse_qs(parsed.query)
counter = query.get("counterModulus", [None])[0]
profile_family = query.get("profileFamily", [None])[0]
allowed_families = {"iec61850-9-2", "9-2le", "iec61869-9"}
if profile_family is not None and profile_family not in allowed_families:
self._json(400, {"fatalError": "Unsupported SV profile family."})
return
stream_family_tokens = []
family_overrides = query.get("profileFamilies", [None])[0]
if family_overrides:
for token in family_overrides.split(","):
index, separator, family = token.partition(":")
if (not separator or not index.isdigit() or
family not in allowed_families):
self._json(400, {"fatalError": "Invalid per-stream SV profile family."})
return
stream_family_tokens.append(f"{int(index)}:{family}")
if counter is not None:
try:
value = int(counter, 10)
Expand All @@ -114,6 +129,10 @@ def do_POST(self) -> None:
temp_path = pathlib.Path(handle.name)

command = [str(self.server.profile_tool), str(temp_path)]
if profile_family is not None:
command += ["--profile-family", profile_family]
for token in stream_family_tokens:
command += ["--stream-profile-family", token]
if counter is not None:
command += ["--counter-modulus", counter]
completed = subprocess.run(
Expand Down
Loading
Loading