From 263fc4ebc797deaae3188900525d6c91a95bfa14 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 10:52:20 +0700 Subject: [PATCH 01/36] feat: converge GUI with SCL-driven SV profile engine --- docs/SV_INTEROPERABILITY_ROADMAP.md | 226 ++++++++++++++ .../sampled_values/publisher_profile.hpp | 287 ++++++++++++++++++ .../sampled_values/rational_schedule.hpp | 71 +++++ include/ariec61850/scl/model.hpp | 16 + src/scl/parser_part_02.inc | 151 +++++++++ src/scl/parser_part_04.inc | 108 ++++++- src/scl/parser_part_05_02.inc | 13 +- src/scl/parser_part_05_04.inc | 14 +- .../fixtures/scl/sv-4800-structured-4i4v.scd | 71 +++++ tests/test_scl.cpp | 97 ++++++ 10 files changed, 1040 insertions(+), 14 deletions(-) create mode 100644 docs/SV_INTEROPERABILITY_ROADMAP.md create mode 100644 include/ariec61850/sampled_values/publisher_profile.hpp create mode 100644 include/ariec61850/sampled_values/rational_schedule.hpp create mode 100644 tests/fixtures/scl/sv-4800-structured-4i4v.scd diff --git a/docs/SV_INTEROPERABILITY_ROADMAP.md b/docs/SV_INTEROPERABILITY_ROADMAP.md new file mode 100644 index 000000000..9d8220261 --- /dev/null +++ b/docs/SV_INTEROPERABILITY_ROADMAP.md @@ -0,0 +1,226 @@ +# Sampled Values Interoperability Roadmap + +ARStack61850 should simulate and analyze Sampled Values streams from standards-described merging-unit configurations **without vendor-specific code paths**. + +The interoperability target is not a list of brands. The target is a growing, testable set of IEC 61850 / instrument-transformer profile semantics that can be compiled from engineering intent and verified against observed wire behavior. + +## North-star behavior + +```text +SCL / CID / SCD / IID optional lab capture + | | + v v +secure SCL parser observed wire fingerprint + | | + v | +DataSet + type resolver | + | | + +-- explicit FCDA leaf | + | | + +-- structured FCDA -> ordered leaf | + | | + +------------------+-------------------+ + v + profile/evidence resolver + | + v + SvPublisherProfile compiler + | + +-- L2 address / VLAN / APPID + +-- svID / confRev / nofASDU + +-- sample-rate and sample-mode semantics + +-- SmvOpts field-presence policy + +-- ordered wire leaf layout + +-- validated sample-counter policy + +-- synchronization policy inputs + | + v + versioned binary device profile + | + v + RTOS deterministic publisher + | + v + hardware TX timestamp evidence +``` + +XML parsing, string resolution, type-template traversal and capture fingerprinting belong on the host. The embedded realtime hot path receives an already validated, immutable profile and prebuilt packet layout. + +## Three truth domains + +ARStack must never collapse these into one source of truth: + +1. **Configured intent** — what SCL/CID/SCD/IID says should exist. +2. **Observed wire facts** — what an independent capture actually contains: field presence, identity, counter progression, cadence and other measurable behavior. +3. **Profile/standard rules** — semantics required by the applicable standards/profile family. + +A conflict is diagnostic evidence, not permission to silently overwrite one domain with another. The engineer or an explicit profile rule decides which behavior is deployed. + +This separation is essential for broad interoperability because an engineering file may not encode every runtime behavior, and a capture may reflect a different configuration revision than the available engineering file. + +## Tolerance contract: forgiving representation, strict semantics + +Real vendor SCL/CID files often carry different namespace prefixes, `Private` blocks, foreign-namespace metadata, lexical formatting and tool-specific ordering around the same IEC 61850 engineering intent. ARStack should understand those differences without weakening the semantic gates that protect deterministic publishing. + +The compatibility engine therefore follows this rule: + +```text +malformed / unsafe XML -> REJECT +well-formed SCL + unfamiliar metadata -> TOLERATE + QUARANTINE/DIAGNOSE +known semantic equivalent -> NORMALIZE + PROVENANCE +unknown mandatory semantic -> MISSING_CONTEXT / UNSUPPORTED +fully resolved runtime profile -> CLASS A / DEPLOYABLE +``` + +Tolerance applies only where equivalence can be established safely. The engine may normalize namespace prefixes, standard lexical equivalents, harmless whitespace, equivalent L2 text representations and known edition/profile differences. It must not make XML element names arbitrarily case-insensitive, invent missing references, infer unknown payload layouts or promote synchronization state merely to make a file parse. + +Vendor extensions are quarantined from the canonical model unless an explicit, validated adapter maps them to a known semantic. Unknown extension data is bounded, non-executable metadata with path/namespace/type provenance; it cannot silently override core IEC 61850 values. + +Each normalized field should eventually carry explainable provenance such as: + +- `exact_scl`; +- `standard_equivalent`; +- `tolerated_lexical`; +- `profile_rule`; +- `vendor_extension`; +- `observed_wire`; +- `user_confirmed`; +- `unresolved`. + +The practical product rule is: **parse broadly, explain normalization, deploy narrowly**. A CID may be accepted for inspection even when some semantics remain unresolved; the same profile is not device-deployable until every mandatory runtime field has passed the Class-A gates. + +## Interoperability rules + +1. **SCL is configured intent.** Do not replace SCL values with convenient defaults when the configuration provides an explicit value. +2. **Resolved type order is wire order.** FCDA order is preserved. When an FCDA points at a structured data object without `daName`, ARStack expands the matching functional-constraint leaves in `DataTypeTemplates` order. +3. **Unsupported means rejected, not guessed.** Unknown basic types, ambiguous DataSet bindings, incomplete address bindings and unresolved structures must produce explicit diagnostics. +4. **Optional fields are profile data.** `SmvOpts` is preserved so canonical ASDU field presence is not inferred from what a particular analyzer prefers. +5. **Timing is rational, not truncated.** A requested rate such as 4800 samples/s must not become a fixed 208 us period. The scheduler distributes integer timer ticks so long-term rate has no truncation drift. +6. **Counter policy is independently validated.** `SmpPerSec` can establish publisher rate, but it must not be treated as universal proof of the `smpCnt` wrap rule. A rate-sized modulus may be shown as a candidate for inspection; device deployment requires an applicable profile rule or observed-evidence confirmation. +7. **Synchronization is evidence-driven.** Configuration and packet shape cannot promote `smpSynch`; measured clock state controls synchronization claims. +8. **Diagnostic traffic is separate.** Bench mirrors/probes must never change canonical stream identity or semantics. +9. **Regression evidence is vendor-neutral.** Real lab configurations may inform anonymous fixtures, but proprietary configuration files, commercial names and product comparisons are not committed. +10. **Tolerance is bounded.** Representation quirks can be normalized when equivalence is known; unresolved mandatory semantics remain non-deployable. + +## Compatibility classes + +### Class A — deterministic publishable + +A stream can be compiled completely: + +- destination MAC and APPID resolved; +- VLAN binding either complete or absent; +- svID resolved; +- sample rate/mode understood; +- nofASDU understood; +- DataSet binding resolved; +- every transmitted leaf has a deterministic supported wire width; +- counter policy is validated, not merely guessed from rate; +- optional-field policy is known; +- any configured-vs-observed conflict required for deployment is resolved explicitly. + +Class A profiles can proceed to the device compiler/runtime. + +### Class B — valid configuration needing runtime/profile context + +The stream is structurally understood but deployment needs additional context. Examples include: + +- samples-per-period mode without nominal system frequency; +- an unresolved sample-counter wrap policy; +- a capture-derived runtime behavior that conflicts with an older engineering file; +- a profile family whose optional-field semantics require an explicit selection. + +Class B is not guessed. The host UI/CLI must request, resolve or validate the missing context explicitly before deployment. + +### Class C — unsupported or ambiguous + +Any unresolved type/layout, ambiguous binding, unknown mandatory profile rule or inconsistent configuration is rejected with a diagnostic. Class C streams are interoperability backlog items, not silent fallbacks. + +## P3-A0 — normalized host profile foundation + +Current goals: + +- preserve `SmvOpts` in the SCL model; +- expand structured FCDA definitions to ordered leaf entries; +- compile `SclSampledValuesStream` into `SvPublisherProfile`; +- derive publisher rate from supported sampling semantics without hard-coding 4000; +- keep sample-counter policy explicit and independently validated; +- provide drift-free rational timer scheduling; +- inspect arbitrary SCL/CID/SCD/IID files with a read-only CLI; +- maintain anonymous fixtures for 4000 and 4800 samples/s families; +- add bounded SCL dialect normalization and vendor-extension quarantine tracked by #40. + +The initial fixed-width compiler intentionally supports only leaf types whose wire size is explicit in ARStack. New types should be added from standards/profile evidence with regression tests rather than broad assumptions. + +## P3-A1 — compiled device profile + +Next: + +- define a versioned binary schema; +- include schema version, total length and integrity check; +- serialize only resolved runtime values, never XML; +- require a validated counter policy before a profile is device-deployable; +- validate on host and device; +- activate profiles atomically; +- prebuild packet templates and patch tables before realtime start; +- move destination MAC, APPID, VLAN, svID, confRev, nofASDU, sample rate, validated counter modulus and dataset layout out of embedded hard-coded constants; +- integrate `RationalTickSchedule` with the ESP32-P4 timer path for non-integer-microsecond rates. + +## P3-A2 — observed fingerprint + interoperability matrix + +Add a read-only capture fingerprint layer that can measure, per stream: + +- destination/source MAC and VLAN visibility; +- APPID and ASDU field presence; +- svID / confRev / nofASDU; +- sample-counter progression, wrap candidates, duplicates and gaps; +- observed frame cadence and rate distribution; +- payload length/layout consistency; +- synchronization values as **observed fields**, not synchronization proof. + +The fingerprint augments SCL; it never silently replaces SCL. Conflicts are surfaced as configured-vs-observed diagnostics. + +Grow coverage by anonymous standards/profile families: + +| Capability | Minimum regression evidence | +|---|---| +| 4000 samples/s | exact 250 us nominal schedule + validated counter policy | +| 4800 samples/s | drift-free rational schedule + validated counter policy | +| Explicit FCDA leaves | ordered primitive + quality layout | +| Structured FCDA | recursive type expansion filtered by FC | +| Optional ASDU fields | each supported `SmvOpts` combination encoded intentionally | +| Address variants | APPID, multicast MAC, tagged/untagged VLAN handling | +| Multiple streams | independent identity, counter and timing state | +| nofASDU variants | deterministic frame construction and counter semantics | +| Additional fixed leaf types | standard-derived width + golden-wire tests | +| Config vs capture conflict | explicit diagnostic, no silent override | +| Vendor metadata / dialect variance | equivalent canonical profile + normalization provenance | + +A profile family enters the supported matrix only when parser/profile tests, encoded-wire tests and physical interoperability evidence agree. + +## Relation to hardware timing work + +Profile correctness and timing correctness are separate gates that converge at the embedded publisher: + +```text +configured/profile truth timing truth +SCL -> SvPublisherProfile timer deadline + | | + +-----------+------------------+ + v + deterministic TX + | + v + EMAC HW timestamp + | + v + evidence report +``` + +A stream can be byte-correct but badly timed, or well timed but configured incorrectly. ARStack must be able to distinguish those failure modes. + +## Definition of success + +The injector should eventually let an engineer select a standards-described SV stream, validate it, optionally compare it with a lab capture fingerprint, bind signal values/scenarios, deploy it to dedicated hardware, and receive deterministic timing evidence without manually editing packet bytes or creating device-specific firmware builds. + +The analyzer should consume the same normalized profile/evidence model for expected-vs-observed checks, so injector and analyzer share one interpretation of stream identity, dataset order, validated counter policy, optional fields and timing expectations. \ No newline at end of file diff --git a/include/ariec61850/sampled_values/publisher_profile.hpp b/include/ariec61850/sampled_values/publisher_profile.hpp new file mode 100644 index 000000000..f95597d6b --- /dev/null +++ b/include/ariec61850/sampled_values/publisher_profile.hpp @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "ariec61850/scl/model.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +enum class SvSampleMode : std::uint8_t { + unknown, + samples_per_period, + samples_per_second, +}; + +enum class SvSampleCounterPolicy : std::uint8_t { + unresolved, + candidate_sample_rate_modulus, + explicit_modulus, +}; + +struct SvAsduOptions final { + bool element_present{}; + bool refresh_time{}; + bool sample_synchronized{}; + bool sample_rate{}; + bool data_set{}; + bool security{}; + bool synch_source_id{}; + + friend bool operator==(const SvAsduOptions&, const SvAsduOptions&) = default; +}; + +struct SvPublisherChannel final { + std::size_t index{}; + std::string signal_reference; + std::string cdc; + std::string basic_type; + bool is_quality{}; + bool is_timestamp{}; + std::uint16_t wire_width_bytes{}; + + friend bool operator==(const SvPublisherChannel&, const SvPublisherChannel&) = default; +}; + +struct SvPublisherProfileCompileContext final { + // SCL describes sampling semantics but does not universally establish + // every runtime sample-counter wrap policy. A standards/profile rule or + // independently observed evidence may validate a modulus explicitly. + std::optional sample_counter_modulus; +}; + +struct SvPublisherProfile final { + std::uint32_t schema_version{1U}; + std::string control_block_reference; + std::string sv_id; + std::string data_set_reference; + + std::array destination_mac{}; + std::uint16_t app_id{}; + bool vlan_present{}; + std::uint16_t vlan_id{}; + std::uint8_t vlan_priority{}; + + std::uint32_t configuration_revision{}; + std::uint32_t sample_rate_value{}; + SvSampleMode sample_mode{SvSampleMode::unknown}; + std::optional publisher_rate_hz; + std::uint16_t no_asdu{1U}; + SvSampleCounterPolicy sample_counter_policy{SvSampleCounterPolicy::unresolved}; + std::optional sample_counter_modulus; + SvAsduOptions asdu_options; + + std::vector channels; + std::size_t payload_size_bytes{}; + + friend bool operator==(const SvPublisherProfile&, const SvPublisherProfile&) = default; +}; + +struct SvPublisherProfileCompileResult final { + std::optional profile; + std::vector errors; + std::vector warnings; + + [[nodiscard]] bool ok() const noexcept { + return profile.has_value() && errors.empty(); + } +}; + +class SvPublisherProfileCompiler final { +public: + [[nodiscard]] static SvPublisherProfileCompileResult compile( + const scl::SclSampledValuesStream& stream, + const SvPublisherProfileCompileContext& context = {}) { + SvPublisherProfileCompileResult result; + SvPublisherProfile profile; + + profile.control_block_reference = stream.control_block_reference; + profile.sv_id = stream.sv_id.empty() ? stream.smv_id : stream.sv_id; + profile.data_set_reference = stream.data_set_reference; + profile.configuration_revision = stream.configuration_revision; + profile.sample_rate_value = stream.sample_rate; + profile.sample_mode = parse_sample_mode(stream.sample_mode); + profile.no_asdu = stream.no_asdu; + profile.asdu_options = compile_options(stream.smv_options); + + if (!stream.address.destination_mac.has_value()) { + result.errors.push_back("SV stream has no valid destination MAC address."); + } else { + profile.destination_mac = *stream.address.destination_mac; + } + if (!stream.address.app_id.has_value()) { + result.errors.push_back("SV stream has no valid APPID."); + } else { + profile.app_id = *stream.address.app_id; + } + + const bool has_vlan_id = stream.address.vlan_id.has_value(); + const bool has_vlan_priority = stream.address.vlan_priority.has_value(); + if (has_vlan_id != has_vlan_priority) { + result.errors.push_back( + "SV stream has an incomplete VLAN binding; VLAN ID and priority must either both be present or both be absent."); + } else if (has_vlan_id) { + profile.vlan_present = true; + profile.vlan_id = *stream.address.vlan_id; + profile.vlan_priority = *stream.address.vlan_priority; + if (profile.vlan_id > 4095U) { + result.errors.push_back("SV VLAN ID exceeds the 12-bit Ethernet VLAN range."); + } + if (profile.vlan_priority > 7U) { + result.errors.push_back("SV VLAN priority exceeds the 3-bit Ethernet PCP range."); + } + } + + if (profile.sv_id.empty()) { + result.errors.push_back("SV stream has no svID/smvID."); + } + if (profile.no_asdu == 0U) { + result.errors.push_back("SV nofASDU must be greater than zero."); + } + if (profile.sample_rate_value == 0U) { + result.errors.push_back("SV sample rate is zero or missing."); + } + + if (profile.sample_mode == SvSampleMode::samples_per_second) { + profile.publisher_rate_hz = profile.sample_rate_value; + } else if (profile.sample_mode == SvSampleMode::samples_per_period) { + result.warnings.push_back( + "SmpPerPeriod requires a nominal-system-frequency input before an absolute publisher rate can be scheduled."); + } else { + result.errors.push_back("SV sample mode is missing or unsupported."); + } + + if (context.sample_counter_modulus.has_value()) { + if (*context.sample_counter_modulus == 0U) { + result.errors.push_back("SV sample-counter modulus must be greater than zero."); + } else { + profile.sample_counter_policy = SvSampleCounterPolicy::explicit_modulus; + profile.sample_counter_modulus = context.sample_counter_modulus; + } + } else if ( + profile.sample_mode == SvSampleMode::samples_per_second && + profile.sample_rate_value > 0U && + profile.sample_rate_value <= std::numeric_limits::max()) { + // Several second-aligned interoperability families use a one-second + // sample-count cycle. Preserve that useful candidate for inspection, + // but mark it non-authoritative: device deployment must validate the + // counter policy from a profile rule or observed evidence first. + profile.sample_counter_policy = + SvSampleCounterPolicy::candidate_sample_rate_modulus; + profile.sample_counter_modulus = + static_cast(profile.sample_rate_value); + result.warnings.push_back( + "SV sample-counter modulus equals the SmpPerSec rate only as an unvalidated candidate; confirm it from the applicable profile rule or observed evidence before deployment."); + } else { + result.warnings.push_back( + "SV sample-counter wrap policy is unresolved; supply a validated profile rule or observed-evidence modulus before deployment."); + } + + if (stream.entries.empty()) { + result.errors.push_back("SV DataSet is empty or unresolved."); + } + + std::size_t payload_size{}; + for (const auto& entry : stream.entries) { + const auto width = wire_width(entry); + if (!width.has_value()) { + result.errors.push_back( + "Unsupported or unresolved SV leaf type for " + entry.signal_reference + + ": '" + entry.basic_type + "'."); + continue; + } + if (payload_size > std::numeric_limits::max() - *width) { + result.errors.push_back("SV payload size overflow."); + break; + } + payload_size += *width; + profile.channels.push_back({ + entry.index, + entry.signal_reference, + entry.cdc, + entry.basic_type, + entry.is_quality, + entry.is_timestamp, + *width, + }); + } + profile.payload_size_bytes = payload_size; + + if (result.errors.empty()) { + result.profile = std::move(profile); + } + return result; + } + +private: + [[nodiscard]] static std::string lower_copy(const std::string_view text) { + std::string result; + result.reserve(text.size()); + for (const char ch : text) { + result.push_back(static_cast( + std::tolower(static_cast(ch)))); + } + return result; + } + + [[nodiscard]] static SvSampleMode parse_sample_mode(const std::string_view text) { + const auto normalized = lower_copy(text); + if (normalized == "smppersec") { + return SvSampleMode::samples_per_second; + } + if (normalized == "smpperperiod") { + return SvSampleMode::samples_per_period; + } + return SvSampleMode::unknown; + } + + [[nodiscard]] static std::optional wire_width( + const scl::SclDataSetEntry& entry) { + if (entry.is_quality || lower_copy(entry.basic_type) == "quality") { + return static_cast(4U); + } + if (entry.is_timestamp || lower_copy(entry.basic_type) == "timestamp") { + return static_cast(8U); + } + + const auto type = lower_copy(entry.basic_type); + if (type == "boolean" || type == "int8" || type == "int8u") { + return static_cast(1U); + } + if (type == "int16" || type == "int16u") { + return static_cast(2U); + } + if (type == "int32" || type == "int32u" || type == "float32") { + return static_cast(4U); + } + if (type == "int64" || type == "int64u" || type == "float64") { + return static_cast(8U); + } + return std::nullopt; + } + + [[nodiscard]] static SvAsduOptions compile_options( + const scl::SclSmvOptions& options) { + return { + options.element_present, + options.refresh_time, + options.sample_synchronized, + options.sample_rate, + options.data_set, + options.security, + options.synch_source_id, + }; + } +}; + +} // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/sampled_values/rational_schedule.hpp b/include/ariec61850/sampled_values/rational_schedule.hpp new file mode 100644 index 000000000..aa31610cc --- /dev/null +++ b/include/ariec61850/sampled_values/rational_schedule.hpp @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +namespace ar::iec61850::sampled_values { + +class RationalTickSchedule final { +public: + RationalTickSchedule( + const std::uint64_t timer_frequency_hz, + const std::uint32_t event_rate_hz) + : timer_frequency_hz_(timer_frequency_hz), + event_rate_hz_(event_rate_hz) { + if (timer_frequency_hz_ == 0U || event_rate_hz_ == 0U) { + throw std::invalid_argument( + "Timer frequency and event rate must both be non-zero."); + } + if (timer_frequency_hz_ < event_rate_hz_) { + throw std::invalid_argument( + "Timer frequency must be at least as high as the event rate."); + } + + base_interval_ticks_ = timer_frequency_hz_ / event_rate_hz_; + remainder_ticks_numerator_ = static_cast( + timer_frequency_hz_ % event_rate_hz_); + } + + [[nodiscard]] std::uint64_t next_interval_ticks() noexcept { + auto interval = base_interval_ticks_; + const auto accumulated = + static_cast(remainder_accumulator_) + + remainder_ticks_numerator_; + if (accumulated >= event_rate_hz_) { + ++interval; + remainder_accumulator_ = static_cast( + accumulated - event_rate_hz_); + } else { + remainder_accumulator_ = static_cast(accumulated); + } + return interval; + } + + void reset() noexcept { + remainder_accumulator_ = 0U; + } + + [[nodiscard]] std::uint64_t timer_frequency_hz() const noexcept { + return timer_frequency_hz_; + } + [[nodiscard]] std::uint32_t event_rate_hz() const noexcept { + return event_rate_hz_; + } + [[nodiscard]] std::uint64_t base_interval_ticks() const noexcept { + return base_interval_ticks_; + } + [[nodiscard]] std::uint32_t remainder_ticks_numerator() const noexcept { + return remainder_ticks_numerator_; + } + +private: + std::uint64_t timer_frequency_hz_{}; + std::uint32_t event_rate_hz_{}; + std::uint64_t base_interval_ticks_{}; + std::uint32_t remainder_ticks_numerator_{}; + std::uint32_t remainder_accumulator_{}; +}; + +} // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/scl/model.hpp b/include/ariec61850/scl/model.hpp index 5b7d3a8ce..b1ecdd523 100644 --- a/include/ariec61850/scl/model.hpp +++ b/include/ariec61850/scl/model.hpp @@ -94,12 +94,28 @@ struct SclGooseStream final : SclProcessBusStream { friend bool operator==(const SclGooseStream&, const SclGooseStream&) = default; }; +struct SclSmvOptions final { + // Preserve both element presence and the normalized boolean attributes. + // This lets later profile compilation distinguish an omitted SmvOpts + // element from an explicitly present element whose options are all false. + bool element_present{}; + bool refresh_time{}; + bool sample_synchronized{}; + bool sample_rate{}; + bool data_set{}; + bool security{}; + bool synch_source_id{}; + + friend bool operator==(const SclSmvOptions&, const SclSmvOptions&) = default; +}; + struct SclSampledValuesStream final : SclProcessBusStream { std::string sv_id; std::string smv_id; std::uint16_t sample_rate{}; std::string sample_mode; std::uint16_t no_asdu{1U}; + SclSmvOptions smv_options; friend bool operator==(const SclSampledValuesStream&, const SclSampledValuesStream&) = default; }; diff --git a/src/scl/parser_part_02.inc b/src/scl/parser_part_02.inc index a334881ef..7c3e3875a 100644 --- a/src/scl/parser_part_02.inc +++ b/src/scl/parser_part_02.inc @@ -203,6 +203,11 @@ public: std::string enum_type; }; + struct ExpandedAttribute final { + std::string da_name; + Resolution resolution; + }; + explicit SclTypeIndex(const XmlNode& root) { for (const auto* ied : direct_children(root, "IED")) { add_first(ieds_, attribute(ied, "name"), ied); @@ -223,3 +228,149 @@ public: } } } + + [[nodiscard]] std::vector expand_data_object( + const std::string& ied_name, + const std::string& ld_inst, + const std::string& prefix, + const std::string& ln_class, + const std::string& ln_inst, + const std::string& do_name, + const std::string& functional_constraint) const { + std::vector expanded; + const auto* logical_node = find_logical_node( + ied_name, + ld_inst, + prefix, + ln_class, + ln_inst); + if (logical_node == nullptr) { + return expanded; + } + + const auto* logical_node_type = find( + logical_node_types_, attribute(logical_node, "lnType")); + const auto do_segments = split_name(do_name); + if (logical_node_type == nullptr || do_segments.empty()) { + return expanded; + } + + const XmlNode* data_object{}; + for (const auto* candidate : direct_children(*logical_node_type, "DO")) { + if (same(attribute(candidate, "name"), do_segments.front())) { + data_object = candidate; + break; + } + } + if (data_object == nullptr) { + return expanded; + } + + auto current_type_id = attribute(data_object, "type"); + const XmlNode* data_object_type = find(data_object_types_, current_type_id); + if (data_object_type == nullptr) { + return expanded; + } + + for (std::size_t index = 1U; index < do_segments.size(); ++index) { + const XmlNode* structured_data_object{}; + for (const auto* candidate : direct_children(*data_object_type, "SDO")) { + if (same(attribute(candidate, "name"), do_segments[index])) { + structured_data_object = candidate; + break; + } + } + if (structured_data_object == nullptr) { + return {}; + } + current_type_id = attribute(structured_data_object, "type"); + data_object_type = find(data_object_types_, current_type_id); + if (data_object_type == nullptr) { + return {}; + } + } + + const auto cdc = attribute(data_object_type, "cdc"); + const auto append_leaf = [&expanded, &cdc]( + const std::string& name, + const std::string& fc, + const XmlNode& node, + const bool resolved) { + const auto basic_type = attribute(&node, "bType"); + const auto type_id = attribute(&node, "type"); + std::string enum_type; + if (same(basic_type, "Enum")) { + enum_type = type_id; + } + expanded.push_back({ + name, + {resolved, fc, cdc, basic_type, type_id, enum_type}, + }); + }; + + const auto append_bdas = [this, &expanded, &cdc]( + auto&& self, + const XmlNode& container, + const std::string& path, + const std::string& fc) -> void { + for (const auto* bda : direct_children(container, "BDA")) { + const auto name = attribute(bda, "name"); + if (name.empty()) { + continue; + } + const auto child_path = path.empty() ? name : path + "." + name; + const auto basic_type = attribute(bda, "bType"); + const auto type_id = attribute(bda, "type"); + if (same(basic_type, "Struct")) { + const auto* nested = find(data_attribute_types_, type_id); + if (nested == nullptr) { + expanded.push_back({ + child_path, + {false, fc, cdc, basic_type, type_id, {}}, + }); + } else { + self(self, *nested, child_path, fc); + } + continue; + } + std::string enum_type; + if (same(basic_type, "Enum") && + enum_type_ids_.contains(normalized_key(type_id))) { + enum_type = type_id; + } + expanded.push_back({ + child_path, + {true, fc, cdc, basic_type, type_id, enum_type}, + }); + } + }; + + for (const auto* data_attribute : direct_children(*data_object_type, "DA")) { + const auto attribute_fc = attribute(data_attribute, "fc"); + if (!functional_constraint.empty() && + !same(attribute_fc, functional_constraint)) { + continue; + } + const auto resolved_fc = functional_constraint.empty() + ? attribute_fc + : functional_constraint; + const auto name = attribute(data_attribute, "name"); + if (name.empty()) { + continue; + } + const auto basic_type = attribute(data_attribute, "bType"); + if (same(basic_type, "Struct")) { + const auto type_id = attribute(data_attribute, "type"); + const auto* attribute_type = find(data_attribute_types_, type_id); + if (attribute_type == nullptr) { + append_leaf(name, resolved_fc, *data_attribute, false); + } else { + append_bdas(append_bdas, *attribute_type, name, resolved_fc); + } + continue; + } + append_leaf(name, resolved_fc, *data_attribute, true); + } + + return expanded; + } \ No newline at end of file diff --git a/src/scl/parser_part_04.inc b/src/scl/parser_part_04.inc index 423bf94dc..27da4a265 100644 --- a/src/scl/parser_part_04.inc +++ b/src/scl/parser_part_04.inc @@ -25,6 +25,102 @@ }; } +std::vector build_data_set_entries( + const std::string& fallback_ied_name, + const XmlNode& fcda, + const std::size_t first_index, + const SclTypeIndex& type_index, + std::vector& warnings) { + const auto explicit_da_name = attribute(&fcda, "daName"); + if (!explicit_da_name.empty()) { + return {build_data_set_entry( + fallback_ied_name, + fcda, + first_index, + type_index, + warnings)}; + } + + auto ied_name = attribute(&fcda, "iedName"); + if (ied_name.empty()) { + ied_name = fallback_ied_name; + } + const auto ld_inst = attribute(&fcda, "ldInst"); + const auto prefix = attribute(&fcda, "prefix"); + const auto ln_class = attribute(&fcda, "lnClass"); + const auto ln_inst = attribute(&fcda, "lnInst"); + const auto do_name = attribute(&fcda, "doName"); + const auto functional_constraint = attribute(&fcda, "fc"); + + const auto expanded = type_index.expand_data_object( + ied_name, + ld_inst, + prefix, + ln_class, + ln_inst, + do_name, + functional_constraint); + if (expanded.empty()) { + const auto reference = build_signal_reference( + ied_name, + ld_inst, + prefix, + ln_class, + ln_inst, + do_name, + {}, + functional_constraint); + warnings.push_back( + "Unable to expand structured FCDA " + reference + + "; preserving the unresolved object entry."); + return {build_data_set_entry( + fallback_ied_name, + fcda, + first_index, + type_index, + warnings)}; + } + + std::vector result; + result.reserve(expanded.size()); + auto index = first_index; + for (const auto& leaf : expanded) { + const auto signal_reference = build_signal_reference( + ied_name, + ld_inst, + prefix, + ln_class, + ln_inst, + do_name, + leaf.da_name, + functional_constraint); + if (!leaf.resolution.resolved) { + warnings.push_back("Type unresolved for " + signal_reference + "."); + } + result.push_back({ + index++, + signal_reference, + ied_name, + ld_inst, + prefix, + ln_class, + ln_inst, + do_name, + leaf.da_name, + functional_constraint.empty() + ? leaf.resolution.functional_constraint + : functional_constraint, + leaf.resolution.cdc, + leaf.resolution.basic_type, + leaf.resolution.type_id, + leaf.resolution.enum_type, + is_quality_attribute(leaf.da_name), + is_timestamp_attribute(leaf.da_name), + }); + } + return result; +} + SclStreamAddress build_address(const XmlNode& owner) { std::unordered_map values; for (const auto* parameter : descendants(owner, "P")) { @@ -202,12 +298,16 @@ SclDocument SclParser::parse( std::size_t entry_index = 1U; for (const auto* fcda : direct_children(*data_set_node, "FCDA")) { - data_set.entries.push_back(build_data_set_entry( + auto entries = build_data_set_entries( ied_name, *fcda, - entry_index++, + entry_index, type_index, - document.warnings)); + document.warnings); + entry_index += entries.size(); + for (auto& entry : entries) { + data_set.entries.push_back(std::move(entry)); + } } document.data_sets.push_back(std::move(data_set)); } @@ -222,4 +322,4 @@ SclDocument SclParser::parse( index); } - std::unordered_map address_index; + std::unordered_map address_index; \ No newline at end of file diff --git a/src/scl/parser_part_05_02.inc b/src/scl/parser_part_05_02.inc index f862ac952..b2795ad2c 100644 --- a/src/scl/parser_part_05_02.inc +++ b/src/scl/parser_part_05_02.inc @@ -11,6 +11,17 @@ stream.no_asdu = std::max( 1U, ushort_attribute(*control, "nofASDU")); + if (const auto* options = first_child(*control, "SmvOpts"); options != nullptr) { + stream.smv_options.element_present = true; + stream.smv_options.refresh_time = bool_attribute(*options, "refreshTime"); + stream.smv_options.sample_synchronized = + bool_attribute(*options, "sampleSynchronized"); + stream.smv_options.sample_rate = bool_attribute(*options, "sampleRate"); + stream.smv_options.data_set = bool_attribute(*options, "dataSet"); + stream.smv_options.security = bool_attribute(*options, "security"); + stream.smv_options.synch_source_id = + bool_attribute(*options, "synchSourceId"); + } document.sampled_values_streams.push_back(std::move(stream)); } } @@ -42,4 +53,4 @@ report.control_block_reference = ied_name + ld_inst + "/" + logical_node_path + (buffered ? "$BR$" : "$RP$") + report.name; report.buffered = buffered; - report.indexed = !same(attribute(control, "indexed"), "false"); + report.indexed = !same(attribute(control, "indexed"), "false"); \ No newline at end of file diff --git a/src/scl/parser_part_05_04.inc b/src/scl/parser_part_05_04.inc index 6c5f89586..d65f63cf2 100644 --- a/src/scl/parser_part_05_04.inc +++ b/src/scl/parser_part_05_04.inc @@ -1,17 +1,13 @@ detect_address_conflicts("SV", document.sampled_values_streams); + // A focused engineering file may intentionally describe only one service + // family (for example, only Sampled Values). Absence of unrelated GOOSE, + // SV, or ReportControl blocks is therefore not a parser warning. Callers + // that require a specific service should validate that requirement in + // their own profile/compiler layer. if (document.data_sets.empty()) { document.warnings.push_back("No DataSet element was found. Semantic mapping will be limited."); } - if (document.goose_streams.empty()) { - document.warnings.push_back("No GSEControl stream was found."); - } - if (document.sampled_values_streams.empty()) { - document.warnings.push_back("No SampledValueControl stream was found."); - } - if (document.report_controls.empty()) { - document.warnings.push_back("No ReportControl block was found."); - } return document; } diff --git a/tests/fixtures/scl/sv-4800-structured-4i4v.scd b/tests/fixtures/scl/sv-4800-structured-4i4v.scd new file mode 100644 index 000000000..299688306 --- /dev/null +++ b/tests/fixtures/scl/sv-4800-structured-4i4v.scd @@ -0,0 +1,71 @@ + + +
+ + + + +
+

0

+

4

+

01-0C-CD-04-00-00

+

4001

+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_scl.cpp b/tests/test_scl.cpp index a501fcc12..770ac8346 100644 --- a/tests/test_scl.cpp +++ b/tests/test_scl.cpp @@ -1,13 +1,17 @@ // SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/sampled_values/publisher_profile.hpp" +#include "ariec61850/sampled_values/rational_schedule.hpp" #include "ariec61850/scl/dataset_reference.hpp" #include "ariec61850/scl/parser.hpp" #include +#include #include #include #include #include +#include #include #include #include @@ -137,6 +141,98 @@ void parser_extracts_multiple_sampled_values_streams_and_conflicts() { })); } +void parser_compiles_structured_4800_sv_profile_without_drift() { + using namespace ar::iec61850::sampled_values; + using namespace ar::iec61850::scl; + + const auto document = SclParser{}.load(fixture("sv-4800-structured-4i4v.scd")); + CHECK(document.sampled_values_streams.size() == 1U); + CHECK(document.data_sets.size() == 1U); + CHECK(document.warnings.empty()); + + const auto& stream = document.sampled_values_streams.front(); + CHECK(stream.address.destination_mac_text == "01:0C:CD:04:00:00"); + CHECK(stream.address.app_id == std::optional{ + static_cast(0x4001U)}); + CHECK(stream.address.vlan_id == std::optional{ + static_cast(0U)}); + CHECK(stream.address.vlan_priority == std::optional{ + static_cast(4U)}); + CHECK(stream.configuration_revision == 100U); + CHECK(stream.sample_rate == 4800U); + CHECK(stream.sample_mode == "SmpPerSec"); + CHECK(stream.no_asdu == 1U); + CHECK(stream.smv_options.element_present); + CHECK(!stream.smv_options.refresh_time); + CHECK(!stream.smv_options.sample_synchronized); + CHECK(!stream.smv_options.sample_rate); + CHECK(!stream.smv_options.data_set); + CHECK(!stream.smv_options.security); + CHECK(!stream.smv_options.synch_source_id); + CHECK(stream.entries.size() == 16U); + + for (std::size_t channel = 0U; channel < 8U; ++channel) { + const auto& value = stream.entries[channel * 2U]; + const auto& quality = stream.entries[channel * 2U + 1U]; + CHECK(value.da_name == "instMag.i"); + CHECK(value.cdc == "SAV"); + CHECK(value.basic_type == "INT32"); + CHECK(!value.is_quality); + CHECK(quality.da_name == "q"); + CHECK(quality.cdc == "SAV"); + CHECK(quality.basic_type == "Quality"); + CHECK(quality.is_quality); + } + + SvPublisherProfileCompileContext context; + context.sample_counter_modulus = static_cast(4800U); + const auto compiled = SvPublisherProfileCompiler::compile(stream, context); + CHECK(compiled.ok()); + CHECK(compiled.profile.has_value()); + const auto& profile = *compiled.profile; + CHECK(profile.app_id == 0x4001U); + CHECK(profile.vlan_present); + CHECK(profile.vlan_id == 0U); + CHECK(profile.vlan_priority == 4U); + CHECK(profile.configuration_revision == 100U); + CHECK(profile.sample_mode == SvSampleMode::samples_per_second); + CHECK(profile.publisher_rate_hz == std::optional{4800U}); + CHECK(profile.sample_counter_policy == SvSampleCounterPolicy::explicit_modulus); + CHECK(profile.sample_counter_modulus == std::optional{ + static_cast(4800U)}); + CHECK(profile.channels.size() == 16U); + CHECK(profile.payload_size_bytes == 64U); + CHECK(profile.asdu_options.element_present); + + RationalTickSchedule schedule_4800{1'000'000U, 4800U}; + std::uint64_t total_ticks{}; + std::uint32_t intervals_208{}; + std::uint32_t intervals_209{}; + for (std::uint32_t sample = 0U; sample < 4800U; ++sample) { + const auto interval = schedule_4800.next_interval_ticks(); + total_ticks += interval; + if (interval == 208U) { + ++intervals_208; + } else if (interval == 209U) { + ++intervals_209; + } else { + CHECK(false); + } + } + CHECK(total_ticks == 1'000'000U); + CHECK(intervals_208 == 3200U); + CHECK(intervals_209 == 1600U); + + RationalTickSchedule schedule_4000{1'000'000U, 4000U}; + total_ticks = 0U; + for (std::uint32_t sample = 0U; sample < 4000U; ++sample) { + const auto interval = schedule_4000.next_interval_ticks(); + CHECK(interval == 250U); + total_ticks += interval; + } + CHECK(total_ticks == 1'000'000U); +} + void dataset_reference_resolver_accepts_canonical_and_local_forms() { using namespace ar::iec61850::scl; @@ -263,6 +359,7 @@ int main() { const std::vector>> tests{ {"SCL minimal station", parser_extracts_minimal_station_semantics}, {"SCL multi-stream", parser_extracts_multiple_sampled_values_streams_and_conflicts}, + {"SCL structured 4800 SV profile", parser_compiles_structured_4800_sv_profile_without_drift}, {"SCL dataset references", dataset_reference_resolver_accepts_canonical_and_local_forms}, {"SCL conflicts and warnings", parser_detects_duplicate_ieds_and_missing_dataset_references}, {"SCL edition detection", parser_detects_editions_from_root_metadata}, From 216af6aa48f77372ad168de084731d56b21007f1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 10:56:30 +0700 Subject: [PATCH 02/36] feat: add SCL-driven SV profile inspection tool --- tools/smv_profile_inspect.cpp | 280 ++++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 tools/smv_profile_inspect.cpp diff --git a/tools/smv_profile_inspect.cpp b/tools/smv_profile_inspect.cpp new file mode 100644 index 000000000..e739cd23e --- /dev/null +++ b/tools/smv_profile_inspect.cpp @@ -0,0 +1,280 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/sampled_values/publisher_profile.hpp" +#include "ariec61850/scl/parser.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using ar::iec61850::sampled_values::SvPublisherProfile; +using ar::iec61850::sampled_values::SvPublisherProfileCompileContext; +using ar::iec61850::sampled_values::SvPublisherProfileCompiler; +using ar::iec61850::sampled_values::SvSampleCounterPolicy; +using ar::iec61850::sampled_values::SvSampleMode; +using ar::iec61850::scl::SclDocument; +using ar::iec61850::scl::SclEdition; + +std::string json_escape(std::string_view text) { + std::ostringstream out; + for (const unsigned char ch : text) { + switch (ch) { + case '"': out << "\\\""; break; + case '\\': out << "\\\\"; break; + case '\b': out << "\\b"; break; + case '\f': out << "\\f"; break; + case '\n': out << "\\n"; break; + case '\r': out << "\\r"; break; + case '\t': out << "\\t"; break; + default: + if (ch < 0x20U) { + out << "\\u" << std::hex << std::setw(4) << std::setfill('0') + << static_cast(ch) << std::dec << std::setfill(' '); + } else { + out << static_cast(ch); + } + } + } + return out.str(); +} + +void quoted(std::ostream& out, std::string_view value) { + out << '"' << json_escape(value) << '"'; +} + +template +void string_array(std::ostream& out, const Range& values) { + out << '['; + bool first = true; + for (const auto& value : values) { + if (!first) out << ','; + first = false; + quoted(out, value); + } + out << ']'; +} + +std::string edition_name(const SclEdition edition) { + switch (edition) { + case SclEdition::edition1: return "1"; + case SclEdition::edition2: return "2"; + case SclEdition::edition21: return "2.1"; + default: return "unknown"; + } +} + +std::string sample_mode_name(const SvSampleMode mode) { + switch (mode) { + case SvSampleMode::samples_per_second: return "SmpPerSec"; + case SvSampleMode::samples_per_period: return "SmpPerPeriod"; + default: return "unknown"; + } +} + +std::string counter_policy_name(const SvSampleCounterPolicy policy) { + switch (policy) { + case SvSampleCounterPolicy::explicit_modulus: return "explicit"; + case SvSampleCounterPolicy::candidate_sample_rate_modulus: return "candidate-rate"; + default: return "unresolved"; + } +} + +std::string mac_text(const std::array& mac) { + std::ostringstream out; + out << std::uppercase << std::hex << std::setfill('0'); + for (std::size_t i = 0; i < mac.size(); ++i) { + if (i != 0U) out << ':'; + out << std::setw(2) << static_cast(mac[i]); + } + return out.str(); +} + +std::string lower_copy(std::string text) { + std::transform(text.begin(), text.end(), text.begin(), [](const unsigned char ch) { + return static_cast(std::tolower(ch)); + }); + return text; +} + +bool current_device_layout_supported(const SvPublisherProfile& profile) { + if (profile.no_asdu != 1U || profile.payload_size_bytes != 64U || + profile.channels.size() != 16U || !profile.publisher_rate_hz.has_value() || + *profile.publisher_rate_hz == 0U || *profile.publisher_rate_hz > 65535U || + profile.sample_counter_policy != SvSampleCounterPolicy::explicit_modulus || + !profile.sample_counter_modulus.has_value()) { + return false; + } + if (profile.asdu_options.refresh_time || profile.asdu_options.security || + profile.asdu_options.synch_source_id) { + return false; + } + for (std::size_t i = 0U; i < profile.channels.size(); ++i) { + const auto& channel = profile.channels[i]; + if (channel.wire_width_bytes != 4U) return false; + if ((i % 2U) == 0U) { + if (channel.is_quality || lower_copy(channel.basic_type) != "int32") return false; + } else if (!channel.is_quality) { + return false; + } + } + return true; +} + +void emit_profile(std::ostream& out, const SvPublisherProfile& p) { + out << '{'; + out << "\"schemaVersion\":" << p.schema_version << ','; + out << "\"controlBlockReference\":"; quoted(out, p.control_block_reference); out << ','; + out << "\"svID\":"; quoted(out, p.sv_id); out << ','; + out << "\"dataSetReference\":"; quoted(out, p.data_set_reference); out << ','; + out << "\"destinationMac\":"; quoted(out, mac_text(p.destination_mac)); out << ','; + out << "\"appID\":" << p.app_id << ','; + out << "\"vlanPresent\":" << (p.vlan_present ? "true" : "false") << ','; + out << "\"vlanID\":" << p.vlan_id << ','; + out << "\"vlanPriority\":" << static_cast(p.vlan_priority) << ','; + out << "\"confRev\":" << p.configuration_revision << ','; + out << "\"sampleRate\":" << p.sample_rate_value << ','; + out << "\"sampleMode\":"; quoted(out, sample_mode_name(p.sample_mode)); out << ','; + out << "\"publisherRateHz\":"; + if (p.publisher_rate_hz) out << *p.publisher_rate_hz; else out << "null"; + out << ','; + out << "\"nofASDU\":" << p.no_asdu << ','; + out << "\"counterPolicy\":"; quoted(out, counter_policy_name(p.sample_counter_policy)); out << ','; + out << "\"counterModulus\":"; + if (p.sample_counter_modulus) out << *p.sample_counter_modulus; else out << "null"; + out << ','; + out << "\"payloadBytes\":" << p.payload_size_bytes << ','; + out << "\"asduOptions\":{"; + out << "\"elementPresent\":" << (p.asdu_options.element_present ? "true" : "false") << ','; + out << "\"refreshTime\":" << (p.asdu_options.refresh_time ? "true" : "false") << ','; + out << "\"sampleSynchronized\":" << (p.asdu_options.sample_synchronized ? "true" : "false") << ','; + out << "\"sampleRate\":" << (p.asdu_options.sample_rate ? "true" : "false") << ','; + out << "\"dataSet\":" << (p.asdu_options.data_set ? "true" : "false") << ','; + out << "\"security\":" << (p.asdu_options.security ? "true" : "false") << ','; + out << "\"synchSourceId\":" << (p.asdu_options.synch_source_id ? "true" : "false") << "},"; + out << "\"channels\":["; + for (std::size_t i = 0; i < p.channels.size(); ++i) { + if (i != 0U) out << ','; + const auto& channel = p.channels[i]; + out << '{'; + out << "\"index\":" << channel.index << ','; + out << "\"signalReference\":"; quoted(out, channel.signal_reference); out << ','; + out << "\"cdc\":"; quoted(out, channel.cdc); out << ','; + out << "\"basicType\":"; quoted(out, channel.basic_type); out << ','; + out << "\"quality\":" << (channel.is_quality ? "true" : "false") << ','; + out << "\"timestamp\":" << (channel.is_timestamp ? "true" : "false") << ','; + out << "\"wireWidth\":" << channel.wire_width_bytes; + out << '}'; + } + out << "]}"; +} + +void emit_document( + std::ostream& out, + const SclDocument& document, + const std::optional counter_modulus) { + out << '{'; + out << "\"schemaVersion\":1,"; + out << "\"source\":"; quoted(out, document.source_name); out << ','; + out << "\"edition\":"; quoted(out, edition_name(document.edition)); out << ','; + out << "\"headerID\":"; quoted(out, document.header_id); out << ','; + out << "\"warnings\":"; string_array(out, document.warnings); out << ','; + out << "\"conflicts\":["; + for (std::size_t i = 0; i < document.conflicts.size(); ++i) { + if (i != 0U) out << ','; + const auto& conflict = document.conflicts[i]; + out << '{'; + out << "\"kind\":"; quoted(out, conflict.kind); out << ','; + out << "\"key\":"; quoted(out, conflict.key); out << ','; + out << "\"description\":"; quoted(out, conflict.description); + out << '}'; + } + out << "],\"streams\":["; + + for (std::size_t index = 0; index < document.sampled_values_streams.size(); ++index) { + if (index != 0U) out << ','; + const auto& stream = document.sampled_values_streams[index]; + SvPublisherProfileCompileContext context; + context.sample_counter_modulus = counter_modulus; + const auto compiled = SvPublisherProfileCompiler::compile(stream, context); + + std::string compatibility = "C"; + std::string device_support = "blocked"; + if (compiled.ok()) { + const auto& profile = *compiled.profile; + compatibility = profile.sample_counter_policy == SvSampleCounterPolicy::explicit_modulus ? "A" : "B"; + if (current_device_layout_supported(profile)) device_support = "ready"; + else if (compatibility == "B") device_support = "needs-counter-confirmation"; + else device_support = "unsupported-layout"; + } + + out << '{'; + out << "\"index\":" << index << ','; + out << "\"ied\":"; quoted(out, stream.ied_name); out << ','; + out << "\"control\":"; quoted(out, stream.control_name); out << ','; + out << "\"controlBlockReference\":"; quoted(out, stream.control_block_reference); out << ','; + out << "\"compatibilityClass\":"; quoted(out, compatibility); out << ','; + out << "\"deviceSupport\":"; quoted(out, device_support); out << ','; + out << "\"errors\":"; string_array(out, compiled.errors); out << ','; + out << "\"warnings\":"; string_array(out, compiled.warnings); out << ','; + out << "\"profile\":"; + if (compiled.profile) emit_profile(out, *compiled.profile); else out << "null"; + out << '}'; + } + out << "]}"; +} + +std::optional parse_u16(std::string_view text) { + try { + const auto value = std::stoul(std::string{text}); + if (value == 0U || value > 65535U) return std::nullopt; + return static_cast(value); + } catch (...) { + return std::nullopt; + } +} + +} // namespace + +int main(int argc, char** argv) { + if (argc < 2) { + std::cerr << "usage: ariec61850_smv_profile_inspect [--counter-modulus N]\n"; + return 2; + } + + std::optional counter_modulus; + for (int i = 2; i < argc; ++i) { + if (std::string_view{argv[i]} == "--counter-modulus" && i + 1 < argc) { + counter_modulus = parse_u16(argv[++i]); + if (!counter_modulus) { + std::cerr << "invalid counter modulus\n"; + return 2; + } + } else { + std::cerr << "unknown argument\n"; + return 2; + } + } + + try { + const auto document = ar::iec61850::scl::SclParser{}.load(std::filesystem::path{argv[1]}); + emit_document(std::cout, document, counter_modulus); + std::cout << '\n'; + return 0; + } catch (const std::exception& error) { + std::cout << "{\"schemaVersion\":1,\"fatalError\":"; + quoted(std::cout, error.what()); + std::cout << "}\n"; + return 1; + } +} From f072855ad1caade4f60e9f5231bda9ad874c94d1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 10:56:53 +0700 Subject: [PATCH 03/36] build(gui): add focused host profile compiler target --- apps/smv_injector_gui/host/CMakeLists.txt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 apps/smv_injector_gui/host/CMakeLists.txt diff --git a/apps/smv_injector_gui/host/CMakeLists.txt b/apps/smv_injector_gui/host/CMakeLists.txt new file mode 100644 index 000000000..529f7b7f7 --- /dev/null +++ b/apps/smv_injector_gui/host/CMakeLists.txt @@ -0,0 +1,14 @@ +cmake_minimum_required(VERSION 3.20) +project(ARStackSmvGuiHost LANGUAGES CXX) + +set(ARIEC61850_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(ARIEC61850_BUILD_TOOLS OFF CACHE BOOL "" FORCE) +set(ARIEC61850_BUILD_FUZZERS OFF CACHE BOOL "" FORCE) +set(ARIEC61850_ENABLE_SANITIZERS OFF CACHE BOOL "" FORCE) + +add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/../../.. arstack-core) + +add_executable(ariec61850_smv_profile_inspect + ${CMAKE_CURRENT_LIST_DIR}/../../../tools/smv_profile_inspect.cpp) +target_link_libraries(ariec61850_smv_profile_inspect PRIVATE ARIEC61850::core) +target_compile_features(ariec61850_smv_profile_inspect PRIVATE cxx_std_20) From bc09fe213381cb72591a566a30f60f2c4faa495c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 10:57:18 +0700 Subject: [PATCH 04/36] feat(gui): serve smart SCL profile inspection locally --- apps/smv_injector_gui/host_server.py | 135 +++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 apps/smv_injector_gui/host_server.py diff --git a/apps/smv_injector_gui/host_server.py b/apps/smv_injector_gui/host_server.py new file mode 100644 index 000000000..656f18059 --- /dev/null +++ b/apps/smv_injector_gui/host_server.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import subprocess +import tempfile +import urllib.parse +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer + +APP_DIR = pathlib.Path(__file__).resolve().parent +MAX_SCL_BYTES = 16 * 1024 * 1024 + + +class Handler(SimpleHTTPRequestHandler): + server_version = "ARStackSmvGui/1" + + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(APP_DIR), **kwargs) + + def end_headers(self) -> None: + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Cross-Origin-Resource-Policy", "same-origin") + super().end_headers() + + def do_POST(self) -> None: + parsed = urllib.parse.urlparse(self.path) + if parsed.path != "/api/scl/inspect": + self.send_error(404) + return + + try: + length = int(self.headers.get("Content-Length", "0")) + except ValueError: + self.send_error(400, "Invalid Content-Length") + return + if length <= 0 or length > MAX_SCL_BYTES: + self.send_error(413, "Engineering file size is invalid") + return + + body = self.rfile.read(length) + query = urllib.parse.parse_qs(parsed.query) + counter = query.get("counterModulus", [None])[0] + if counter is not None: + try: + value = int(counter, 10) + if value <= 0 or value > 65535: + raise ValueError + except ValueError: + self._json(400, {"fatalError": "Counter modulus must be 1..65535."}) + return + + suffix = pathlib.Path(self.headers.get("X-File-Name", "engineering.scl")).suffix + if not suffix or len(suffix) > 12: + suffix = ".scl" + + temp_path = None + try: + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as handle: + handle.write(body) + temp_path = pathlib.Path(handle.name) + + command = [str(self.server.profile_tool), str(temp_path)] + if counter is not None: + command += ["--counter-modulus", counter] + completed = subprocess.run( + command, + capture_output=True, + text=True, + timeout=12, + check=False, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, + ) + payload_text = completed.stdout.strip() + if not payload_text: + raise RuntimeError(completed.stderr.strip() or "Profile compiler returned no output.") + payload = json.loads(payload_text) + status = 200 if completed.returncode == 0 else 422 + self._json(status, payload) + except subprocess.TimeoutExpired: + self._json(504, {"fatalError": "SCL profile compilation timed out."}) + except Exception as error: # local control-plane boundary + self._json(500, {"fatalError": str(error)}) + finally: + if temp_path is not None: + try: + temp_path.unlink(missing_ok=True) + except OSError: + pass + + def _json(self, status: int, payload: dict) -> None: + encoded = json.dumps(payload, separators=(",", ":")).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def log_message(self, fmt: str, *args) -> None: + print(f"{self.address_string()} - {fmt % args}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--profile-tool", required=True) + parser.add_argument("--port", type=int, default=8765) + args = parser.parse_args() + + tool = pathlib.Path(args.profile_tool).resolve() + if not tool.is_file(): + raise SystemExit(f"Profile compiler not found: {tool}") + + server = ThreadingHTTPServer(("127.0.0.1", args.port), Handler) + server.profile_tool = tool + print("ARStack61850 SMV Injector GUI") + print(f"Local UI: http://127.0.0.1:{args.port}/") + print(f"Smart SCL engine: {tool}") + print("IMPORTANT: close idf.py monitor first so the GUI can own the serial port.") + print("Press Ctrl+C to stop the local control-plane server.") + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 5db08d90b961bd5b5cba918c5a74d931bec17b83 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 10:57:53 +0700 Subject: [PATCH 05/36] feat(gui): launch smart SCL profile backend --- apps/smv_injector_gui/run.cmd | 52 +++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/apps/smv_injector_gui/run.cmd b/apps/smv_injector_gui/run.cmd index 0b8f3b250..992496e12 100644 --- a/apps/smv_injector_gui/run.cmd +++ b/apps/smv_injector_gui/run.cmd @@ -1,6 +1,10 @@ @echo off setlocal set "PORT=8765" +set "APP_DIR=%~dp0" +for %%I in ("%APP_DIR%..\..\..") do set "REPO_ROOT=%%~fI" +set "BUILD_DIR=%REPO_ROOT%\build-smv-gui" +set "PROFILE_TOOL=%BUILD_DIR%\ariec61850_smv_profile_inspect.exe" where python >nul 2>nul if errorlevel 1 ( @@ -10,34 +14,54 @@ if errorlevel 1 ( exit /b 1 ) -if not exist "%~dp0index.html" ( - echo ERROR: GUI index.html was not found next to run.cmd. - echo Expected: %~dp0index.html +where cmake >nul 2>nul +if errorlevel 1 ( + echo CMake was not found on PATH. + echo Run this launcher from the ESP-IDF shell. pause exit /b 1 ) -rem Serve from the launcher directory itself. Do not pass %%~dp0 to -rem http.server --directory: %%~dp0 ends in a backslash on Windows and can be -rem mis-parsed by the child process command-line quoting, causing HTTP 404s. -pushd "%~dp0" +where ninja >nul 2>nul if errorlevel 1 ( - echo ERROR: could not enter GUI directory: %~dp0 + echo Ninja was not found on PATH. + echo Run this launcher from the ESP-IDF shell. + pause + exit /b 1 +) + +if not exist "%APP_DIR%index.html" ( + echo ERROR: GUI index.html was not found next to run.cmd. pause exit /b 1 ) echo ARStack61850 SMV Injector GUI +echo Building smart SCL profile engine... +cmake -S "%APP_DIR%host" -B "%BUILD_DIR%" -G Ninja -DCMAKE_BUILD_TYPE=Release +if errorlevel 1 goto :build_failed +cmake --build "%BUILD_DIR%" --target ariec61850_smv_profile_inspect +if errorlevel 1 goto :build_failed + +if not exist "%PROFILE_TOOL%" ( + echo ERROR: profile compiler was not produced: %PROFILE_TOOL% + pause + exit /b 1 +) + echo Local UI: http://127.0.0.1:%PORT%/ -echo Serving from: %CD% +echo Smart SCL engine: %PROFILE_TOOL% echo. echo IMPORTANT: close idf.py monitor first so the GUI can own the serial port. -echo Press Ctrl+C in this window to stop the local UI server. +echo Press Ctrl+C in this window to stop the local control-plane server. echo. start "" "http://127.0.0.1:%PORT%/" -python -m http.server %PORT% --bind 127.0.0.1 -set "SERVER_EXIT=%ERRORLEVEL%" +python "%APP_DIR%host_server.py" --profile-tool "%PROFILE_TOOL%" --port %PORT% +exit /b %ERRORLEVEL% -popd -exit /b %SERVER_EXIT% +:build_failed +echo. +echo ERROR: smart SCL profile engine build failed. +pause +exit /b 1 From 926f9c8834c88860b0f69e0afa4c201a4643940a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 10:59:09 +0700 Subject: [PATCH 06/36] feat(embedded): add immutable runtime SV profile state --- .../main/runtime_profile.hpp | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 embedded/esp32p4_smv_injector/main/runtime_profile.hpp diff --git a/embedded/esp32p4_smv_injector/main/runtime_profile.hpp b/embedded/esp32p4_smv_injector/main/runtime_profile.hpp new file mode 100644 index 000000000..b42ba1eda --- /dev/null +++ b/embedded/esp32p4_smv_injector/main/runtime_profile.hpp @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +namespace ar::esp32p4::smv { + +struct RuntimePublisherProfile final { + std::uint32_t schema_version{1U}; + std::array destination_mac{0x01U, 0x0CU, 0xCDU, 0x04U, 0x00U, 0x01U}; + std::uint16_t app_id{0x4000U}; + bool vlan_present{true}; + std::uint16_t vlan_id{0U}; + std::uint8_t vlan_priority{4U}; + std::uint32_t configuration_revision{1U}; + std::uint32_t publisher_rate_hz{4000U}; + std::uint16_t sample_counter_modulus{4000U}; + std::uint16_t no_asdu{1U}; + bool include_data_set{false}; + bool include_sample_rate{false}; + std::array sv_id{}; + std::array data_set_reference{}; + std::uint64_t generation{1U}; +}; + +void runtime_profile_initialize() noexcept; +[[nodiscard]] RuntimePublisherProfile runtime_profile_snapshot() noexcept; +[[nodiscard]] bool runtime_profile_commit(const RuntimePublisherProfile& requested) noexcept; +[[nodiscard]] bool runtime_profile_validate(const RuntimePublisherProfile& profile) noexcept; + +} // namespace ar::esp32p4::smv From 334db43cddf4588f68b50703070e152ec5405ea0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 10:59:27 +0700 Subject: [PATCH 07/36] feat(embedded): implement runtime SV profile commit boundary --- .../main/runtime_profile.cpp | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 embedded/esp32p4_smv_injector/main/runtime_profile.cpp diff --git a/embedded/esp32p4_smv_injector/main/runtime_profile.cpp b/embedded/esp32p4_smv_injector/main/runtime_profile.cpp new file mode 100644 index 000000000..2eb2dc12e --- /dev/null +++ b/embedded/esp32p4_smv_injector/main/runtime_profile.cpp @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "runtime_profile.hpp" + +#include "freertos/FreeRTOS.h" + +#include +#include + +namespace ar::esp32p4::smv { +namespace { + +portMUX_TYPE g_profile_mux = portMUX_INITIALIZER_UNLOCKED; +RuntimePublisherProfile g_profile{}; + +void copy_text(std::array& destination, const char* source) noexcept { + destination.fill('\0'); + if (source == nullptr) return; + std::strncpy(destination.data(), source, destination.size() - 1U); +} + +} // namespace + +void runtime_profile_initialize() noexcept { + RuntimePublisherProfile initial; + copy_text(initial.sv_id, "MU01_SV1"); + initial.data_set_reference.fill('\0'); + initial.generation = 1U; + portENTER_CRITICAL(&g_profile_mux); + g_profile = initial; + portEXIT_CRITICAL(&g_profile_mux); +} + +RuntimePublisherProfile runtime_profile_snapshot() noexcept { + RuntimePublisherProfile snapshot; + portENTER_CRITICAL(&g_profile_mux); + snapshot = g_profile; + portEXIT_CRITICAL(&g_profile_mux); + return snapshot; +} + +bool runtime_profile_validate(const RuntimePublisherProfile& profile) noexcept { + if (profile.schema_version != 1U || profile.app_id == 0U || + profile.publisher_rate_hz == 0U || profile.publisher_rate_hz > 65535U || + profile.sample_counter_modulus == 0U || profile.no_asdu != 1U || + profile.sv_id[0] == '\0') { + return false; + } + if (profile.vlan_present && (profile.vlan_id > 4095U || profile.vlan_priority > 7U)) { + return false; + } + if (profile.include_data_set && profile.data_set_reference[0] == '\0') { + return false; + } + return true; +} + +bool runtime_profile_commit(const RuntimePublisherProfile& requested) noexcept { + if (!runtime_profile_validate(requested)) return false; + portENTER_CRITICAL(&g_profile_mux); + auto next = requested; + next.generation = g_profile.generation + 1U; + g_profile = next; + portEXIT_CRITICAL(&g_profile_mux); + return true; +} + +} // namespace ar::esp32p4::smv From f2baaab07c84a9c7dc426862cdbc4af66e9e73e2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 10:59:49 +0700 Subject: [PATCH 08/36] build(embedded): include runtime SV profile state --- embedded/esp32p4_smv_injector/main/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/embedded/esp32p4_smv_injector/main/CMakeLists.txt b/embedded/esp32p4_smv_injector/main/CMakeLists.txt index c36fda4bd..c27cc8050 100644 --- a/embedded/esp32p4_smv_injector/main/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/main/CMakeLists.txt @@ -3,6 +3,7 @@ idf_component_register( "app_main.cpp" "ethernet_port.c" "live_control.cpp" + "runtime_profile.cpp" INCLUDE_DIRS "." REQUIRES arstack_process_bus esp_eth esp_event esp_timer esp_driver_gptimer freertos) From 5f997b398784110551f5fcee64784c785c205945 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:00:16 +0700 Subject: [PATCH 09/36] feat(embedded): add machine profile control interface --- .../esp32p4_smv_injector/main/profile_control.hpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 embedded/esp32p4_smv_injector/main/profile_control.hpp diff --git a/embedded/esp32p4_smv_injector/main/profile_control.hpp b/embedded/esp32p4_smv_injector/main/profile_control.hpp new file mode 100644 index 000000000..542546268 --- /dev/null +++ b/embedded/esp32p4_smv_injector/main/profile_control.hpp @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +namespace ar::esp32p4::smv { + +// Handles the machine-facing PROFILE subcommands used by the local GUI. +// The operator-facing surface remains the GUI; this text transport is a +// bounded development control protocol, not a user workflow. +void handle_profile_command(char* arguments) noexcept; + +} // namespace ar::esp32p4::smv From ab4c1fd54553356b856102567a9c057c9f2ebefd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:00:53 +0700 Subject: [PATCH 10/36] feat(embedded): stage and commit validated SV profiles --- .../main/profile_control.cpp | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 embedded/esp32p4_smv_injector/main/profile_control.cpp diff --git a/embedded/esp32p4_smv_injector/main/profile_control.cpp b/embedded/esp32p4_smv_injector/main/profile_control.cpp new file mode 100644 index 000000000..bd8db6c6f --- /dev/null +++ b/embedded/esp32p4_smv_injector/main/profile_control.cpp @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "profile_control.hpp" + +#include "live_control.hpp" +#include "runtime_profile.hpp" + +#include "esp_log.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::esp32p4::smv { +namespace { + +constexpr char kTag[] = "ar_smv_profile"; +constexpr char kDelimiters[] = " \t\r\n"; +RuntimePublisherProfile g_staging{}; +bool g_staging_active = false; + +void uppercase_ascii(char* text) noexcept { + if (text == nullptr) return; + for (; *text != '\0'; ++text) { + *text = static_cast(std::toupper(static_cast(*text))); + } +} + +bool parse_u32(const char* text, std::uint32_t& value) noexcept { + if (text == nullptr || *text == '\0' || *text == '-') return false; + errno = 0; + char* end{}; + const unsigned long parsed = std::strtoul(text, &end, 0); + if (errno != 0 || end == text || *end != '\0' || + parsed > std::numeric_limits::max()) return false; + value = static_cast(parsed); + return true; +} + +int hex_nibble(const char ch) noexcept { + if (ch >= '0' && ch <= '9') return ch - '0'; + if (ch >= 'a' && ch <= 'f') return 10 + ch - 'a'; + if (ch >= 'A' && ch <= 'F') return 10 + ch - 'A'; + return -1; +} + +template +bool decode_hex_text(const char* text, std::array& destination) noexcept { + destination.fill('\0'); + if (text == nullptr) return false; + if (std::strcmp(text, "-") == 0) return true; + const std::size_t length = std::strlen(text); + if (length == 0U || (length % 2U) != 0U || length / 2U >= N) return false; + for (std::size_t i = 0U; i < length; i += 2U) { + const int high = hex_nibble(text[i]); + const int low = hex_nibble(text[i + 1U]); + if (high < 0 || low < 0) return false; + const unsigned char byte = static_cast((high << 4) | low); + if (byte == 0U) return false; + destination[i / 2U] = static_cast(byte); + } + return true; +} + +bool decode_mac(const char* text, std::array& mac) noexcept { + if (text == nullptr || std::strlen(text) != 12U) return false; + for (std::size_t i = 0U; i < mac.size(); ++i) { + const int high = hex_nibble(text[i * 2U]); + const int low = hex_nibble(text[i * 2U + 1U]); + if (high < 0 || low < 0) return false; + mac[i] = static_cast((high << 4) | low); + } + return true; +} + +bool no_extra(char** save) noexcept { + return strtok_r(nullptr, kDelimiters, save) == nullptr; +} + +void print_profile(const RuntimePublisherProfile& profile) noexcept { + ESP_LOGI(kTag, + "PROFILE generation=%llu svID=%s APPID=0x%04X rate=%lu wrap=%u confRev=%lu VLAN=%u/%u/%u dataSetField=%u sampleRateField=%u", + static_cast(profile.generation), + profile.sv_id.data(), + static_cast(profile.app_id), + static_cast(profile.publisher_rate_hz), + static_cast(profile.sample_counter_modulus), + static_cast(profile.configuration_revision), + profile.vlan_present ? 1U : 0U, + static_cast(profile.vlan_id), + static_cast(profile.vlan_priority), + profile.include_data_set ? 1U : 0U, + profile.include_sample_rate ? 1U : 0U); +} + +} // namespace + +void handle_profile_command(char* arguments) noexcept { + char* save{}; + char* subcommand = strtok_r(arguments, kDelimiters, &save); + if (subcommand == nullptr) { + ESP_LOGE(kTag, "PROFILE subcommand required"); + return; + } + uppercase_ascii(subcommand); + + if (std::strcmp(subcommand, "SHOW") == 0) { + if (!no_extra(&save)) { + ESP_LOGE(kTag, "Usage: PROFILE SHOW"); + return; + } + print_profile(runtime_profile_snapshot()); + return; + } + + if (live_tx_running()) { + ESP_LOGE(kTag, "PROFILE rejected: stop the publisher before changing stream identity/layout"); + return; + } + + if (std::strcmp(subcommand, "BEGIN") == 0) { + if (!no_extra(&save)) { + ESP_LOGE(kTag, "Usage: PROFILE BEGIN"); + return; + } + g_staging = runtime_profile_snapshot(); + g_staging_active = true; + ESP_LOGI(kTag, "PROFILE staging started"); + return; + } + + if (!g_staging_active) { + ESP_LOGE(kTag, "PROFILE staging is not active; send PROFILE BEGIN first"); + return; + } + + if (std::strcmp(subcommand, "ID") == 0) { + char* value = strtok_r(nullptr, kDelimiters, &save); + if (!decode_hex_text(value, g_staging.sv_id) || !no_extra(&save)) { + ESP_LOGE(kTag, "Invalid PROFILE ID payload"); + return; + } + return; + } + + if (std::strcmp(subcommand, "DATASET") == 0) { + char* value = strtok_r(nullptr, kDelimiters, &save); + if (!decode_hex_text(value, g_staging.data_set_reference) || !no_extra(&save)) { + ESP_LOGE(kTag, "Invalid PROFILE DATASET payload"); + return; + } + return; + } + + if (std::strcmp(subcommand, "L2") == 0) { + char* appid_text = strtok_r(nullptr, kDelimiters, &save); + char* mac_text = strtok_r(nullptr, kDelimiters, &save); + char* vlan_present_text = strtok_r(nullptr, kDelimiters, &save); + char* vlan_id_text = strtok_r(nullptr, kDelimiters, &save); + char* pcp_text = strtok_r(nullptr, kDelimiters, &save); + std::uint32_t appid{}; + std::uint32_t vlan_present{}; + std::uint32_t vlan_id{}; + std::uint32_t pcp{}; + std::array mac{}; + if (!parse_u32(appid_text, appid) || appid == 0U || appid > 65535U || + !decode_mac(mac_text, mac) || + !parse_u32(vlan_present_text, vlan_present) || vlan_present > 1U || + !parse_u32(vlan_id_text, vlan_id) || vlan_id > 4095U || + !parse_u32(pcp_text, pcp) || pcp > 7U || !no_extra(&save)) { + ESP_LOGE(kTag, "Invalid PROFILE L2 payload"); + return; + } + g_staging.app_id = static_cast(appid); + g_staging.destination_mac = mac; + g_staging.vlan_present = vlan_present != 0U; + g_staging.vlan_id = static_cast(vlan_id); + g_staging.vlan_priority = static_cast(pcp); + return; + } + + if (std::strcmp(subcommand, "SV") == 0) { + char* confrev_text = strtok_r(nullptr, kDelimiters, &save); + char* rate_text = strtok_r(nullptr, kDelimiters, &save); + char* modulus_text = strtok_r(nullptr, kDelimiters, &save); + char* no_asdu_text = strtok_r(nullptr, kDelimiters, &save); + char* flags_text = strtok_r(nullptr, kDelimiters, &save); + std::uint32_t confrev{}; + std::uint32_t rate{}; + std::uint32_t modulus{}; + std::uint32_t no_asdu{}; + std::uint32_t flags{}; + if (!parse_u32(confrev_text, confrev) || + !parse_u32(rate_text, rate) || rate == 0U || rate > 65535U || + !parse_u32(modulus_text, modulus) || modulus == 0U || modulus > 65535U || + !parse_u32(no_asdu_text, no_asdu) || no_asdu != 1U || + !parse_u32(flags_text, flags) || flags > 3U || !no_extra(&save)) { + ESP_LOGE(kTag, "Invalid PROFILE SV payload"); + return; + } + g_staging.configuration_revision = confrev; + g_staging.publisher_rate_hz = rate; + g_staging.sample_counter_modulus = static_cast(modulus); + g_staging.no_asdu = static_cast(no_asdu); + g_staging.include_data_set = (flags & 0x1U) != 0U; + g_staging.include_sample_rate = (flags & 0x2U) != 0U; + return; + } + + if (std::strcmp(subcommand, "COMMIT") == 0) { + if (!no_extra(&save)) { + ESP_LOGE(kTag, "Usage: PROFILE COMMIT"); + return; + } + if (!runtime_profile_commit(g_staging)) { + ESP_LOGE(kTag, "PROFILE commit rejected by runtime validation"); + return; + } + g_staging_active = false; + const auto active = runtime_profile_snapshot(); + ESP_LOGI(kTag, + "PROFILE committed generation=%llu svID=%s APPID=0x%04X rate=%lu wrap=%u", + static_cast(active.generation), + active.sv_id.data(), + static_cast(active.app_id), + static_cast(active.publisher_rate_hz), + static_cast(active.sample_counter_modulus)); + return; + } + + ESP_LOGE(kTag, "Unknown PROFILE subcommand"); +} + +} // namespace ar::esp32p4::smv From 23f3f1ab9eeb5f0b4b2e363b3cfca2cb6b8950c1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:01:13 +0700 Subject: [PATCH 11/36] build(embedded): include profile control transport --- embedded/esp32p4_smv_injector/main/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/embedded/esp32p4_smv_injector/main/CMakeLists.txt b/embedded/esp32p4_smv_injector/main/CMakeLists.txt index c27cc8050..bd43d1526 100644 --- a/embedded/esp32p4_smv_injector/main/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/main/CMakeLists.txt @@ -3,6 +3,7 @@ idf_component_register( "app_main.cpp" "ethernet_port.c" "live_control.cpp" + "profile_control.cpp" "runtime_profile.cpp" INCLUDE_DIRS "." REQUIRES arstack_process_bus esp_eth esp_event esp_timer esp_driver_gptimer freertos) From d6681ab01b337f04d495c0a719e024b46d7e6a09 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:01:36 +0700 Subject: [PATCH 12/36] feat(embedded): wake publisher on GUI control transitions --- embedded/esp32p4_smv_injector/main/live_control.hpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/live_control.hpp b/embedded/esp32p4_smv_injector/main/live_control.hpp index b8523323d..d9aa717ab 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.hpp +++ b/embedded/esp32p4_smv_injector/main/live_control.hpp @@ -3,6 +3,8 @@ #pragma once #include "ariec61850/sampled_values/live_signal_state.hpp" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" #include @@ -12,6 +14,8 @@ void live_control_initialize( std::int32_t current_rms_counts, std::int32_t voltage_rms_counts) noexcept; +void live_control_bind_publisher_task(TaskHandle_t task) noexcept; + [[nodiscard]] ar::iec61850::sampled_values::SvLiveSignalState live_signal_snapshot() noexcept; From 91689bd6064128c5570650fa1616df1c3357d285 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:02:18 +0700 Subject: [PATCH 13/36] feat(embedded): route GUI profile commands and publisher wakes --- .../main/live_control.cpp | 115 ++++++------------ 1 file changed, 40 insertions(+), 75 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 88e1089d1..dea956d9f 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "live_control.hpp" +#include "profile_control.hpp" #include "esp_log.h" #include "freertos/FreeRTOS.h" @@ -31,29 +32,27 @@ constexpr std::string_view kTokenDelimiters{" \t\r\n"}; SvLiveSignalBank g_signal_bank; std::atomic g_running{false}; std::atomic g_start_request{false}; +std::atomic g_publisher_task{nullptr}; -void uppercase_ascii(char* text) noexcept { - if (text == nullptr) { - return; +void wake_publisher() noexcept { + const auto task = g_publisher_task.load(std::memory_order_acquire); + if (task != nullptr) { + xTaskNotifyGive(task); } +} + +void uppercase_ascii(char* text) noexcept { + if (text == nullptr) return; for (; *text != '\0'; ++text) { *text = static_cast(std::toupper(static_cast(*text))); } } -// The bench console is a safety-sensitive control surface: random UART bytes, -// terminal escape sequences or UTF-8 fragments must never be normalized into a -// valid command. Accept printable 7-bit ASCII plus normal line whitespace only; -// reject the whole line otherwise. bool is_safe_ascii_command_line(const char* line) noexcept { - if (line == nullptr) { - return false; - } - + if (line == nullptr) return false; bool has_payload = false; for (const auto* cursor = reinterpret_cast(line); - *cursor != 0U; - ++cursor) { + *cursor != 0U; ++cursor) { const auto byte = *cursor; if (byte == static_cast(' ') || byte == static_cast('\t') || @@ -61,9 +60,7 @@ bool is_safe_ascii_command_line(const char* line) noexcept { byte == static_cast('\n')) { continue; } - if (byte < 0x20U || byte > 0x7EU) { - return false; - } + if (byte < 0x20U || byte > 0x7EU) return false; has_payload = true; } return has_payload; @@ -74,32 +71,24 @@ bool has_extra_token(char** save) noexcept { } bool parse_i32(const char* text, std::int32_t& value) noexcept { - if (text == nullptr || *text == '\0') { - return false; - } + if (text == nullptr || *text == '\0') return false; errno = 0; char* end{}; const long parsed = std::strtol(text, &end, 0); if (errno != 0 || end == text || *end != '\0' || parsed < std::numeric_limits::min() || - parsed > std::numeric_limits::max()) { - return false; - } + parsed > std::numeric_limits::max()) return false; value = static_cast(parsed); return true; } bool parse_u32(const char* text, std::uint32_t& value) noexcept { - if (text == nullptr || *text == '\0' || *text == '-') { - return false; - } + if (text == nullptr || *text == '\0' || *text == '-') return false; errno = 0; char* end{}; const unsigned long parsed = std::strtoul(text, &end, 0); if (errno != 0 || end == text || *end != '\0' || - parsed > std::numeric_limits::max()) { - return false; - } + parsed > std::numeric_limits::max()) return false; value = static_cast(parsed); return true; } @@ -111,7 +100,7 @@ void print_help() noexcept { ESP_LOGI(kTag, " SET [quality]"); ESP_LOGI(kTag, " ENABLE <0|1>"); ESP_LOGI(kTag, " QUALITY "); - ESP_LOGI(kTag, "SET/FREQ/QUALITY/ENABLE commit coherently; stream identity is unchanged."); + ESP_LOGI(kTag, "GUI profile deployment uses bounded PROFILE subcommands while STOPPED."); } void print_state() noexcept { @@ -140,8 +129,7 @@ bool publish_state(const SvLiveSignalState& state) noexcept { return false; } const auto active = g_signal_bank.snapshot(); - ESP_LOGI(kTag, - "Live signal generation %llu committed", + ESP_LOGI(kTag, "Live signal generation %llu committed", static_cast(active.generation)); return true; } @@ -149,9 +137,7 @@ bool publish_state(const SvLiveSignalState& state) noexcept { void handle_line(char* line) noexcept { char* save{}; char* command = strtok_r(line, kTokenDelimiters.data(), &save); - if (command == nullptr) { - return; - } + if (command == nullptr) return; uppercase_ascii(command); if (std::strcmp(command, "START") == 0) { @@ -161,7 +147,8 @@ void handle_line(char* line) noexcept { } g_start_request.store(true, std::memory_order_release); g_running.store(true, std::memory_order_release); - ESP_LOGI(kTag, "START accepted: phase and smpCnt reset at the next sample boundary"); + wake_publisher(); + ESP_LOGI(kTag, "START accepted: profile armed, phase and smpCnt reset at first sample boundary"); return; } if (std::strcmp(command, "STOP") == 0) { @@ -170,6 +157,7 @@ void handle_line(char* line) noexcept { return; } g_running.store(false, std::memory_order_release); + wake_publisher(); ESP_LOGI(kTag, "STOP accepted: SV transmission suppressed"); return; } @@ -189,15 +177,17 @@ void handle_line(char* line) noexcept { print_help(); return; } + if (std::strcmp(command, "PROFILE") == 0) { + handle_profile_command(save); + return; + } if (std::strcmp(command, "ZERO") == 0) { if (has_extra_token(&save)) { ESP_LOGE(kTag, "Usage: ZERO"); return; } auto state = g_signal_bank.snapshot(); - for (auto& channel : state.channels) { - channel.rms_counts = 0; - } + for (auto& channel : state.channels) channel.rms_counts = 0; static_cast(publish_state(state)); return; } @@ -239,8 +229,7 @@ void handle_line(char* line) noexcept { std::int32_t rms{}; std::int32_t phase{}; if (!parse_i32(rms_text, rms) || !parse_i32(phase_text, phase)) { - ESP_LOGE(kTag, - "Usage: SET [quality]"); + ESP_LOGE(kTag, "Usage: SET [quality]"); return; } std::uint32_t quality{}; @@ -249,15 +238,12 @@ void handle_line(char* line) noexcept { return; } if (has_extra_token(&save)) { - ESP_LOGE(kTag, - "Usage: SET [quality]"); + ESP_LOGE(kTag, "Usage: SET [quality]"); return; } channel.rms_counts = rms; channel.phase_millidegrees = phase; - if (quality_text != nullptr) { - channel.quality = quality; - } + if (quality_text != nullptr) channel.quality = quality; static_cast(publish_state(state)); return; } @@ -294,19 +280,19 @@ void live_control_initialize( const std::int32_t current_rms_counts, const std::int32_t voltage_rms_counts) noexcept { auto initial = g_signal_bank.snapshot(); - for (std::size_t index = 0U; index < 3U; ++index) { - initial.channels[index].rms_counts = current_rms_counts; - } + for (std::size_t index = 0U; index < 3U; ++index) initial.channels[index].rms_counts = current_rms_counts; initial.channels[3].rms_counts = 0; - for (std::size_t index = 4U; index < 7U; ++index) { - initial.channels[index].rms_counts = voltage_rms_counts; - } + for (std::size_t index = 4U; index < 7U; ++index) initial.channels[index].rms_counts = voltage_rms_counts; initial.channels[7].rms_counts = 0; static_cast(g_signal_bank.publish(initial)); g_running.store(false, std::memory_order_release); g_start_request.store(false, std::memory_order_release); } +void live_control_bind_publisher_task(const TaskHandle_t task) noexcept { + g_publisher_task.store(task, std::memory_order_release); +} + SvLiveSignalState live_signal_snapshot() noexcept { return g_signal_bank.snapshot(); } @@ -338,11 +324,6 @@ void live_control_task(void*) noexcept { } const auto byte = static_cast(input); - - // The ESP-IDF monitor forwards interactive input byte-by-byte. Assemble - // those bytes here and execute only after Enter; std::fgets() is not - // suitable for this VFS because a short UART read can look like a - // complete stdio record. if (byte == static_cast('\r') || byte == static_cast('\n')) { if (discard_until_eol) { @@ -352,12 +333,7 @@ void live_control_task(void*) noexcept { ESP_LOGW(kTag, "Discarded invalid/overlong bench command"); continue; } - - if (length == 0U) { - // Ignore the second half of CRLF and empty Enter presses. - continue; - } - + if (length == 0U) continue; line[length] = '\0'; if (!is_safe_ascii_command_line(line.data())) { ESP_LOGW(kTag, "Discarded invalid bench command"); @@ -369,12 +345,7 @@ void live_control_task(void*) noexcept { continue; } - if (discard_until_eol) { - continue; - } - - // Support normal terminal editing without treating backspace/delete as - // a command byte. + if (discard_until_eol) continue; if (byte == 0x08U || byte == 0x7FU) { if (length > 0U) { --length; @@ -387,16 +358,10 @@ void live_control_task(void*) noexcept { byte == static_cast(' ') || byte == static_cast('\t') || (byte >= 0x21U && byte <= 0x7EU); - if (!accepted) { + if (!accepted || length + 1U >= line.size()) { discard_until_eol = true; continue; } - - if (length + 1U >= line.size()) { - discard_until_eol = true; - continue; - } - line[length++] = static_cast(byte); } } From 0c766f88598f845b1328727fd6030f896a22f31b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:03:50 +0700 Subject: [PATCH 14/36] feat(embedded): expose publisher fail-closed stop --- embedded/esp32p4_smv_injector/main/live_control.hpp | 1 + 1 file changed, 1 insertion(+) diff --git a/embedded/esp32p4_smv_injector/main/live_control.hpp b/embedded/esp32p4_smv_injector/main/live_control.hpp index d9aa717ab..d80370d02 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.hpp +++ b/embedded/esp32p4_smv_injector/main/live_control.hpp @@ -15,6 +15,7 @@ void live_control_initialize( std::int32_t voltage_rms_counts) noexcept; void live_control_bind_publisher_task(TaskHandle_t task) noexcept; +void live_control_force_stop() noexcept; [[nodiscard]] ar::iec61850::sampled_values::SvLiveSignalState live_signal_snapshot() noexcept; From 204261478386b39c570e5a7cdc146a7f25c304a2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:05:26 +0700 Subject: [PATCH 15/36] feat(embedded): arm SCL-driven profiles with rational realtime scheduling --- .../esp32p4_smv_injector/main/app_main.cpp | 390 +++++++++--------- 1 file changed, 188 insertions(+), 202 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index f12130ab3..9fae74595 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -9,7 +9,7 @@ #include "driver/gptimer.h" #include "ethernet_port.h" #include "live_control.hpp" -#include "esp_attr.h" +#include "runtime_profile.hpp" #include "esp_err.h" #include "esp_eth.h" #include "esp_event.h" @@ -31,10 +31,15 @@ namespace { +using ar::esp32p4::smv::RuntimePublisherProfile; +using ar::esp32p4::smv::live_control_bind_publisher_task; using ar::esp32p4::smv::live_control_initialize; using ar::esp32p4::smv::live_control_task; using ar::esp32p4::smv::live_signal_snapshot; using ar::esp32p4::smv::live_tx_running; +using ar::esp32p4::smv::runtime_profile_initialize; +using ar::esp32p4::smv::runtime_profile_snapshot; +using ar::esp32p4::smv::runtime_profile_validate; using ar::esp32p4::smv::take_start_request; using ar::iec61850::ethernet::MacAddress; using ar::iec61850::ethernet::VlanTag; @@ -47,25 +52,12 @@ using ar::iec61850::sampled_values::SvLiveSignalState; constexpr char kTag[] = "ar_smv_p3"; constexpr EventBits_t kLinkUpBit = BIT0; - -// Canonical development identity remains fixed until the compiled-device -// profile work moves these fields behind the validated publisher profile. -constexpr std::uint16_t kCanonicalAppId = 0x4000U; -constexpr char kCanonicalSvId[] = "MU01_SV1"; -constexpr std::array kSvMulticastMac{ - 0x01U, 0x0CU, 0xCDU, 0x04U, 0x00U, 0x01U}; - -// The broadcast path is a separate diagnostic stream and never changes the -// canonical identity or sequence semantics. constexpr std::uint16_t kMirrorAppId = 0x4F01U; constexpr char kMirrorSvId[] = "AR_DIAG_SV1"; - -constexpr std::uint16_t kSampleCountWrap = 4000U; -constexpr std::uint32_t kSampleRateHz = 4000U; -constexpr std::int64_t kSamplePeriodUs = 250; constexpr std::uint16_t kSampleModeSamplesPerSecond = 1U; constexpr std::uint16_t kDiagnosticEtherType = 0x88B5U; constexpr std::uint32_t kStatsMergeEvery = 400U; +constexpr std::uint32_t kTimerResolutionHz = 1000000U; EventGroupHandle_t g_link_events = nullptr; portMUX_TYPE g_stats_mux = portMUX_INITIALIZER_UNLOCKED; @@ -94,6 +86,38 @@ struct PacketTemplate final { using WaveformRow = SvFixedPointSineEngine::SampleRow; +struct RationalScheduleCursor final { + std::uint32_t rate_hz{}; + std::uint32_t base_ticks{}; + std::uint32_t remainder{}; + std::uint32_t accumulator{}; + + void reset(const std::uint32_t rate) noexcept { + rate_hz = rate; + base_ticks = kTimerResolutionHz / rate_hz; + remainder = kTimerResolutionHz % rate_hz; + accumulator = 0U; + } + + [[nodiscard]] std::uint32_t next_ticks() noexcept { + std::uint32_t ticks = base_ticks; + accumulator += remainder; + if (accumulator >= rate_hz) { + accumulator -= rate_hz; + ++ticks; + } + return ticks; + } +}; + +struct SampleClockContext final { + TaskHandle_t publisher_task{}; + RationalScheduleCursor schedule{}; + gptimer_alarm_config_t next_alarm{}; +}; + +SampleClockContext g_sample_clock_context{}; + void eth_event_handler(void*, esp_event_base_t, const std::int32_t event_id, void*) { switch (event_id) { case ETHERNET_EVENT_CONNECTED: @@ -132,45 +156,43 @@ void write_i32_be(std::uint8_t* destination, const std::int32_t value) noexcept } std::vector make_4i4v_payload() { - // Current development layout: Ia, Ib, Ic, In, Ua, Ub, Uc, Un. - // Each value is INT32 followed by UINT32 Quality. return std::vector(8U * 8U, 0U); } SampledValuesFrame make_runtime_frame( const std::array& source_mac, - const bool use_vlan, + const RuntimePublisherProfile& profile, const bool diagnostic_mirror) { SampledValueAsdu asdu; - asdu.sv_id = diagnostic_mirror ? kMirrorSvId : kCanonicalSvId; - asdu.data_set_reference.clear(); + asdu.sv_id = diagnostic_mirror ? kMirrorSvId : profile.sv_id.data(); + asdu.data_set_reference = + (!diagnostic_mirror && profile.include_data_set) ? profile.data_set_reference.data() : ""; - // Fixed-width placeholders let the realtime task patch fields in place - // without BER rebuilds or packet-length changes. + // Keep BER widths fixed so the realtime path only patches bytes in place. asdu.sample_count = 0x0100U; asdu.configuration_revision = 0x01000000U; asdu.reference_time.reset(); + // No disciplined PTP clock exists yet, so never claim synchronization. asdu.sample_synchronization = 0U; - if (diagnostic_mirror) { - asdu.sample_rate = static_cast(kSampleRateHz); + if (diagnostic_mirror || profile.include_sample_rate) { + asdu.sample_rate = static_cast(profile.publisher_rate_hz); asdu.sample_mode = kSampleModeSamplesPerSecond; } else { asdu.sample_rate.reset(); asdu.sample_mode.reset(); } - asdu.sample_payload = make_4i4v_payload(); SampledValuesFrame frame; - frame.destination = MacAddress{std::span{kSvMulticastMac}}; + frame.destination = MacAddress{std::span{profile.destination_mac}}; frame.source = MacAddress{std::span{source_mac}}; - if (use_vlan) { - frame.vlan = VlanTag{4U, 0U}; + if (!diagnostic_mirror && profile.vlan_present) { + frame.vlan = VlanTag{profile.vlan_priority, profile.vlan_id}; } else { frame.vlan.reset(); } - frame.app_id = diagnostic_mirror ? kMirrorAppId : kCanonicalAppId; + frame.app_id = diagnostic_mirror ? kMirrorAppId : profile.app_id; frame.reserved1 = 0U; frame.reserved2 = 0U; frame.pdu = SampledValuesPdu{{std::move(asdu)}}; @@ -193,13 +215,13 @@ bool find_fixed_field( bool build_packet_template( const std::array& source_mac, - const bool use_vlan, + const RuntimePublisherProfile& profile, const bool broadcast_destination, const bool diagnostic_mirror, PacketTemplate& packet) { packet = {}; packet.bytes = SampledValuesFrameCodec::encode( - make_runtime_frame(source_mac, use_vlan, diagnostic_mirror)); + make_runtime_frame(source_mac, profile, diagnostic_mirror)); if (broadcast_destination) { std::fill_n(packet.bytes.begin(), 6U, std::uint8_t{0xFFU}); @@ -212,14 +234,13 @@ bool build_packet_template( ESP_LOGE(kTag, "Failed to locate fixed SV patch fields in encoded template"); return false; } - if (packet.sample_payload_offset + 64U > packet.bytes.size()) { ESP_LOGE(kTag, "SV payload patch region exceeds encoded frame"); return false; } write_u16_be(packet.bytes.data() + packet.sample_count_offset, 0U); - write_u32_be(packet.bytes.data() + conf_rev_offset, 1U); + write_u32_be(packet.bytes.data() + conf_rev_offset, profile.configuration_revision); return true; } @@ -229,7 +250,6 @@ void patch_packet( const WaveformRow& values, const SvLiveSignalState& signal_state) noexcept { write_u16_be(packet.bytes.data() + packet.sample_count_offset, sample_count); - for (std::size_t channel = 0U; channel < values.size(); ++channel) { const auto base = packet.sample_payload_offset + channel * 8U; write_i32_be(packet.bytes.data() + base, values[channel]); @@ -240,33 +260,26 @@ void patch_packet( #if CONFIG_AR_SMV_DIAGNOSTIC_PROBES std::array make_probe( const std::array& source_mac, + const std::array& destination_mac, const bool multicast) { std::array frame{}; - for (std::size_t index = 0U; index < 6U; ++index) { - frame[index] = multicast ? kSvMulticastMac[index] : 0xFFU; + frame[index] = multicast ? destination_mac[index] : 0xFFU; frame[6U + index] = source_mac[index]; } - frame[12] = static_cast((kDiagnosticEtherType >> 8U) & 0xFFU); frame[13] = static_cast(kDiagnosticEtherType & 0xFFU); - - constexpr char payload[] = "ARSTACK-P3-LIVE-CONTROL-PROBE"; + constexpr char payload[] = "ARSTACK-P3-PROFILE-PROBE"; for (std::size_t index = 0U; - index < sizeof(payload) - 1U && 14U + index < frame.size(); - ++index) { + index < sizeof(payload) - 1U && 14U + index < frame.size(); ++index) { frame[14U + index] = static_cast(payload[index]); } - return frame; } #endif void merge_stats(TimingStats& local) noexcept { - if (local.samples == 0U) { - return; - } - + if (local.samples == 0U) return; portENTER_CRITICAL(&g_stats_mux); g_interval_stats.samples += local.samples; g_interval_stats.canonical_ok += local.canonical_ok; @@ -275,52 +288,66 @@ void merge_stats(TimingStats& local) noexcept { g_interval_stats.mirror_fail += local.mirror_fail; g_interval_stats.missed_slots += local.missed_slots; g_interval_stats.lateness_sum_us += local.lateness_sum_us; - g_interval_stats.lateness_min_us = - std::min(g_interval_stats.lateness_min_us, local.lateness_min_us); - g_interval_stats.lateness_max_us = - std::max(g_interval_stats.lateness_max_us, local.lateness_max_us); + g_interval_stats.lateness_min_us = std::min(g_interval_stats.lateness_min_us, local.lateness_min_us); + g_interval_stats.lateness_max_us = std::max(g_interval_stats.lateness_max_us, local.lateness_max_us); g_interval_stats.last_sample_count = local.last_sample_count; g_interval_stats.last_signal_generation = local.last_signal_generation; portEXIT_CRITICAL(&g_stats_mux); - local = TimingStats{}; } +void reset_interval_stats() noexcept { + portENTER_CRITICAL(&g_stats_mux); + g_interval_stats = TimingStats{}; + portEXIT_CRITICAL(&g_stats_mux); +} + void update_local_timing( TimingStats& local, const std::int64_t lateness_us, const std::uint64_t missed_slots, const std::uint16_t sample_count, const std::uint64_t signal_generation) noexcept { - const auto bounded_lateness = static_cast(std::clamp( + const auto bounded = static_cast(std::clamp( lateness_us, std::numeric_limits::min(), std::numeric_limits::max())); - ++local.samples; local.missed_slots += missed_slots; local.lateness_sum_us += lateness_us; - local.lateness_min_us = std::min(local.lateness_min_us, bounded_lateness); - local.lateness_max_us = std::max(local.lateness_max_us, bounded_lateness); + local.lateness_min_us = std::min(local.lateness_min_us, bounded); + local.lateness_max_us = std::max(local.lateness_max_us, bounded); local.last_sample_count = sample_count; local.last_signal_generation = signal_generation; } -bool IRAM_ATTR sample_alarm_callback( - gptimer_handle_t, - const gptimer_alarm_event_data_t*, +bool sample_alarm_callback( + gptimer_handle_t timer, + const gptimer_alarm_event_data_t* event, void* user_context) { - auto task = static_cast(user_context); + auto* context = static_cast(user_context); + const auto interval = context->schedule.next_ticks(); + context->next_alarm = {}; + context->next_alarm.alarm_count = event->alarm_value + interval; + context->next_alarm.flags.auto_reload_on_alarm = false; + static_cast(gptimer_set_alarm_action(timer, &context->next_alarm)); + BaseType_t high_priority_task_woken = pdFALSE; - vTaskNotifyGiveFromISR(task, &high_priority_task_woken); + vTaskNotifyGiveFromISR(context->publisher_task, &high_priority_task_woken); return high_priority_task_woken == pdTRUE; } -gptimer_handle_t start_sample_clock(TaskHandle_t publisher_task_handle) { +gptimer_handle_t start_sample_clock( + TaskHandle_t publisher_task_handle, + const std::uint32_t publisher_rate_hz) { + g_sample_clock_context = {}; + g_sample_clock_context.publisher_task = publisher_task_handle; + g_sample_clock_context.schedule.reset(publisher_rate_hz); + gptimer_config_t timer_config{}; timer_config.clk_src = GPTIMER_CLK_SRC_DEFAULT; timer_config.direction = GPTIMER_COUNT_UP; - timer_config.resolution_hz = 1000000U; + timer_config.resolution_hz = kTimerResolutionHz; gptimer_handle_t timer = nullptr; ESP_ERROR_CHECK(gptimer_new_timer(&timer_config, &timer)); @@ -328,21 +355,31 @@ gptimer_handle_t start_sample_clock(TaskHandle_t publisher_task_handle) { gptimer_event_callbacks_t callbacks{}; callbacks.on_alarm = &sample_alarm_callback; ESP_ERROR_CHECK(gptimer_register_event_callbacks( - timer, &callbacks, publisher_task_handle)); + timer, &callbacks, &g_sample_clock_context)); - gptimer_alarm_config_t alarm_config{}; - alarm_config.alarm_count = static_cast(kSamplePeriodUs); - alarm_config.reload_count = 0U; - alarm_config.flags.auto_reload_on_alarm = true; - ESP_ERROR_CHECK(gptimer_set_alarm_action(timer, &alarm_config)); + g_sample_clock_context.next_alarm = {}; + g_sample_clock_context.next_alarm.alarm_count = + g_sample_clock_context.schedule.next_ticks(); + g_sample_clock_context.next_alarm.flags.auto_reload_on_alarm = false; + ESP_ERROR_CHECK(gptimer_set_alarm_action(timer, &g_sample_clock_context.next_alarm)); ESP_ERROR_CHECK(gptimer_enable(timer)); ESP_ERROR_CHECK(gptimer_start(timer)); return timer; } +void stop_sample_clock(gptimer_handle_t& timer) noexcept { + if (timer == nullptr) return; + static_cast(gptimer_stop(timer)); + static_cast(gptimer_disable(timer)); + static_cast(gptimer_del_timer(timer)); + timer = nullptr; +} + void publisher_task(void* argument) { #if CONFIG_AR_SMV_LIVE_TX && CONFIG_AR_SMV_P2_REALTIME_50HZ const auto eth_handle = static_cast(argument); + const auto task_handle = xTaskGetCurrentTaskHandle(); + live_control_bind_publisher_task(task_handle); ESP_LOGW(kTag, "LIVE SV TX ENABLED: isolated lab network only"); ESP_LOGI(kTag, "Waiting for Ethernet link..."); @@ -350,129 +387,123 @@ void publisher_task(void* argument) { std::array source_mac{}; ESP_ERROR_CHECK(esp_eth_ioctl(eth_handle, ETH_CMD_G_MAC_ADDR, source_mac.data())); - - ESP_LOGI(kTag, - "EMAC source MAC=%02X:%02X:%02X:%02X:%02X:%02X", + ESP_LOGI(kTag, "EMAC source MAC=%02X:%02X:%02X:%02X:%02X:%02X", source_mac[0], source_mac[1], source_mac[2], source_mac[3], source_mac[4], source_mac[5]); - PacketTemplate canonical; - PacketTemplate mirror; - - if (!build_packet_template( - source_mac, -#if CONFIG_AR_SMV_CANONICAL_VLAN - true, -#else - false, -#endif - false, - false, - canonical)) { - ESP_LOGE(kTag, "Cannot start publisher: canonical template build failed"); - vTaskDelete(nullptr); - return; - } - -#if CONFIG_AR_SMV_BROADCAST_MIRROR - if (!build_packet_template(source_mac, false, true, true, mirror)) { - ESP_LOGE(kTag, "Cannot start publisher: diagnostic mirror template build failed"); - vTaskDelete(nullptr); - return; - } -#endif - -#if CONFIG_AR_SMV_DIAGNOSTIC_PROBES - const auto probe_broadcast = make_probe(source_mac, false); - const auto probe_multicast = make_probe(source_mac, true); -#endif - const auto initial_signal = live_signal_snapshot(); + const auto initial_profile = runtime_profile_snapshot(); ESP_LOGI(kTag, - "Live signal engine ready: publisher=%u fps, signal=%u mHz, generation=%llu; type START to transmit", - static_cast(kSampleRateHz), + "Live signal engine ready: profile=%s rate=%lu fps signal=%u mHz generation=%llu; use GUI START", + initial_profile.sv_id.data(), + static_cast(initial_profile.publisher_rate_hz), static_cast(initial_signal.frequency_millihz), static_cast(initial_signal.generation)); - ESP_LOGI(kTag, - "Development profile: svID=%s APPID=0x%04X canonical smpCnt wrap=%u %s", - kCanonicalSvId, - static_cast(kCanonicalAppId), - static_cast(kSampleCountWrap), -#if CONFIG_AR_SMV_CANONICAL_VLAN - "VLAN PCP4/VID0" -#else - "untagged" -#endif - ); - ESP_LOGI(kTag, - "Fixed template: canonical len=%u smpCnt@%u seqData@%u", - static_cast(canonical.bytes.size()), - static_cast(canonical.sample_count_offset), - static_cast(canonical.sample_payload_offset)); + PacketTemplate canonical; + PacketTemplate mirror; + RuntimePublisherProfile active_profile = initial_profile; static SvFixedPointSineEngine signal_engine; - - vTaskDelay(pdMS_TO_TICKS(100)); - const gptimer_handle_t sample_clock = start_sample_clock(xTaskGetCurrentTaskHandle()); - (void)sample_clock; - ESP_LOGI(kTag, - "Sample clock: GPTimer 1 MHz, periodic alarm every %lld us; publisher blocks between alarms", - static_cast(kSamplePeriodUs)); - + gptimer_handle_t sample_clock = nullptr; + RationalScheduleCursor expected_schedule{}; std::uint16_t canonical_sample_count = 0U; std::uint16_t mirror_sample_count = 0U; TimingStats local{}; bool schedule_anchored = false; std::int64_t expected_wake_us = 0; +#if CONFIG_AR_SMV_DIAGNOSTIC_PROBES + std::array probe_broadcast{}; + std::array probe_multicast{}; +#endif + while (true) { const std::uint32_t pending_ticks = ulTaskNotifyTake(pdTRUE, portMAX_DELAY); - if (pending_ticks == 0U) { - continue; - } + if (pending_ticks == 0U) continue; if (take_start_request()) { + stop_sample_clock(sample_clock); + active_profile = runtime_profile_snapshot(); + if (!runtime_profile_validate(active_profile)) { + ESP_LOGE(kTag, "START rejected: active runtime profile is invalid"); + continue; + } + if (!build_packet_template(source_mac, active_profile, false, false, canonical)) { + ESP_LOGE(kTag, "START rejected: canonical template build failed"); + continue; + } +#if CONFIG_AR_SMV_BROADCAST_MIRROR + if (!build_packet_template(source_mac, active_profile, true, true, mirror)) { + ESP_LOGE(kTag, "START rejected: diagnostic mirror template build failed"); + continue; + } +#endif +#if CONFIG_AR_SMV_DIAGNOSTIC_PROBES + probe_broadcast = make_probe(source_mac, active_profile.destination_mac, false); + probe_multicast = make_probe(source_mac, active_profile.destination_mac, true); +#endif canonical_sample_count = 0U; mirror_sample_count = 0U; signal_engine.reset_phase(); - schedule_anchored = false; local = TimingStats{}; + reset_interval_stats(); + schedule_anchored = false; + expected_schedule.reset(active_profile.publisher_rate_hz); + static_cast(expected_schedule.next_ticks()); // first alarm interval + sample_clock = start_sample_clock(task_handle, active_profile.publisher_rate_hz); + + ESP_LOGI(kTag, + "PROFILE armed generation=%llu svID=%s APPID=0x%04X rate=%lu wrap=%u confRev=%lu VLAN=%u/%u/%u frameLen=%u", + static_cast(active_profile.generation), + active_profile.sv_id.data(), + static_cast(active_profile.app_id), + static_cast(active_profile.publisher_rate_hz), + static_cast(active_profile.sample_counter_modulus), + static_cast(active_profile.configuration_revision), + active_profile.vlan_present ? 1U : 0U, + static_cast(active_profile.vlan_id), + static_cast(active_profile.vlan_priority), + static_cast(canonical.bytes.size())); + continue; } if (!live_tx_running()) { + stop_sample_clock(sample_clock); schedule_anchored = false; continue; } + if (sample_clock == nullptr) continue; const std::int64_t actual_wake_us = esp_timer_get_time(); std::uint64_t missed_slots = 0U; - if (!schedule_anchored) { expected_wake_us = actual_wake_us; schedule_anchored = true; } else { - expected_wake_us += - static_cast(pending_ticks) * kSamplePeriodUs; + std::uint64_t elapsed_ticks = 0U; + for (std::uint32_t i = 0U; i < pending_ticks; ++i) { + elapsed_ticks += expected_schedule.next_ticks(); + } + expected_wake_us += static_cast(elapsed_ticks); } const auto signal_state = live_signal_snapshot(); - if (pending_ticks > 1U) { missed_slots = static_cast(pending_ticks - 1U); canonical_sample_count = static_cast( (static_cast(canonical_sample_count) + missed_slots) % - kSampleCountWrap); + active_profile.sample_counter_modulus); mirror_sample_count = static_cast( static_cast(mirror_sample_count) + static_cast(missed_slots)); signal_engine.advance( signal_state, - kSampleRateHz, + active_profile.publisher_rate_hz, static_cast(missed_slots)); } const std::int64_t lateness_us = actual_wake_us - expected_wake_us; - const auto row = signal_engine.next(signal_state, kSampleRateHz); + const auto row = signal_engine.next(signal_state, active_profile.publisher_rate_hz); if ((xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U) { ++local.canonical_fail; @@ -481,54 +512,35 @@ void publisher_task(void* argument) { #endif } else { patch_packet(canonical, canonical_sample_count, row, signal_state); - const esp_err_t canonical_result = esp_eth_transmit(eth_handle, canonical.bytes.data(), canonical.bytes.size()); - if (canonical_result == ESP_OK) { - ++local.canonical_ok; - } else { - ++local.canonical_fail; - } + if (canonical_result == ESP_OK) ++local.canonical_ok; + else ++local.canonical_fail; #if CONFIG_AR_SMV_BROADCAST_MIRROR patch_packet(mirror, mirror_sample_count, row, signal_state); const esp_err_t mirror_result = esp_eth_transmit(eth_handle, mirror.bytes.data(), mirror.bytes.size()); - if (mirror_result == ESP_OK) { - ++local.mirror_ok; - } else { - ++local.mirror_fail; - } + if (mirror_result == ESP_OK) ++local.mirror_ok; + else ++local.mirror_fail; #endif #if CONFIG_AR_SMV_DIAGNOSTIC_PROBES - if ((canonical_sample_count % kSampleCountWrap) == 0U) { - (void)esp_eth_transmit( - eth_handle, - const_cast(probe_broadcast.data()), - probe_broadcast.size()); - (void)esp_eth_transmit( - eth_handle, - const_cast(probe_multicast.data()), - probe_multicast.size()); + if (canonical_sample_count == 0U) { + static_cast(esp_eth_transmit( + eth_handle, probe_broadcast.data(), probe_broadcast.size())); + static_cast(esp_eth_transmit( + eth_handle, probe_multicast.data(), probe_multicast.size())); } #endif } update_local_timing( - local, - lateness_us, - missed_slots, - canonical_sample_count, - signal_state.generation); - + local, lateness_us, missed_slots, canonical_sample_count, signal_state.generation); canonical_sample_count = static_cast( - (canonical_sample_count + 1U) % kSampleCountWrap); + (canonical_sample_count + 1U) % active_profile.sample_counter_modulus); mirror_sample_count = static_cast(mirror_sample_count + 1U); - - if (local.samples >= kStatsMergeEvery) { - merge_stats(local); - } + if (local.samples >= kStatsMergeEvery) merge_stats(local); } #else (void)argument; @@ -540,20 +552,15 @@ void publisher_task(void* argument) { void telemetry_task(void*) { while (true) { vTaskDelay(pdMS_TO_TICKS(1000)); - TimingStats snapshot{}; portENTER_CRITICAL(&g_stats_mux); snapshot = g_interval_stats; g_interval_stats = TimingStats{}; portEXIT_CRITICAL(&g_stats_mux); - - if (snapshot.samples == 0U) { - continue; - } + if (snapshot.samples == 0U) continue; const std::int64_t mean_lateness_us = snapshot.lateness_sum_us / static_cast(snapshot.samples); - ESP_LOGI(kTag, "timing: samples=%llu (~%llu fps) MC ok=%llu fail=%llu mirror ok=%llu fail=%llu missed=%llu wake_late_us[min/mean/max]=%ld/%lld/%ld smpCnt=%u signal_gen=%llu", static_cast(snapshot.samples), @@ -589,48 +596,27 @@ extern "C" void app_main(void) { ESP_LOGE(kTag, "ESP32-P4 Ethernet bring-up failed"); return; } - ESP_ERROR_CHECK(esp_eth_start(eth_handle)); #if CONFIG_AR_SMV_LIVE_TX && CONFIG_AR_SMV_P2_REALTIME_50HZ - // Existing menuconfig values seed the first live generation. Current - // development wire scaling is 1 mA/count for I and 10 mV/count for U. + runtime_profile_initialize(); live_control_initialize( static_cast(CONFIG_AR_SMV_CURRENT_RMS_MA), static_cast(CONFIG_AR_SMV_VOLTAGE_RMS_MV / 10)); if (xTaskCreatePinnedToCore( - &live_control_task, - "ar_smv_control", - 6144, - nullptr, - 1, - nullptr, - 0) != pdPASS) { + &live_control_task, "ar_smv_control", 7168, nullptr, 1, nullptr, 0) != pdPASS) { ESP_LOGE(kTag, "Failed to create live control task on CPU0"); return; } - if (xTaskCreatePinnedToCore( - &publisher_task, - "ar_smv_publisher", - 8192, - eth_handle, - configMAX_PRIORITIES - 2, - nullptr, - 1) != pdPASS) { + &publisher_task, "ar_smv_publisher", 9216, eth_handle, + configMAX_PRIORITIES - 2, nullptr, 1) != pdPASS) { ESP_LOGE(kTag, "Failed to create realtime publisher task on CPU1"); return; } - if (xTaskCreatePinnedToCore( - &telemetry_task, - "ar_smv_telemetry", - 4096, - nullptr, - 2, - nullptr, - 0) != pdPASS) { + &telemetry_task, "ar_smv_telemetry", 4096, nullptr, 2, nullptr, 0) != pdPASS) { ESP_LOGE(kTag, "Failed to create telemetry task on CPU0"); return; } From 006e89f68e1cb5520f223ab6a6a619b8bb5ee63a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:06:50 +0700 Subject: [PATCH 16/36] feat(gui): connect SCL compiler to profile deployment workflow --- apps/smv_injector_gui/profile_bridge.js | 321 ++++++++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 apps/smv_injector_gui/profile_bridge.js diff --git a/apps/smv_injector_gui/profile_bridge.js b/apps/smv_injector_gui/profile_bridge.js new file mode 100644 index 000000000..e3de88b4b --- /dev/null +++ b/apps/smv_injector_gui/profile_bridge.js @@ -0,0 +1,321 @@ +"use strict"; + +const profileBridge = { + file: null, + inspection: null, + selectedIndex: 0, + deployed: false, + deploying: false, + currentCountsPerAmp: 1000, + voltageCountsPerVolt: 100, +}; + +function utf8Hex(text) { + const bytes = new TextEncoder().encode(text ?? ""); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +function macHex(text) { + return String(text ?? "").replace(/[^0-9a-f]/gi, "").toUpperCase(); +} + +function selectedCompiledStream() { + return profileBridge.inspection?.streams?.find((item) => item.index === profileBridge.selectedIndex) ?? null; +} + +function installProfileUi() { + const streamPanel = document.querySelector(".stream-panel"); + const microcopy = streamPanel?.querySelector(".microcopy"); + if (!streamPanel || !microcopy) return; + + microcopy.textContent = "Import engineering data through the C++ IEC 61850 engine. Representation differences may be normalized, but unresolved semantics block deployment."; + + const bridge = document.createElement("section"); + bridge.className = "profile-bridge-card"; + bridge.innerHTML = ` +
+
+
Engineering profile compiler
+ No SCL profile selected +
+ — +
+
+ +
Destination—
+
APPID—
+
Sample rate—
+
Payload—
+
Device support—
+
+ + +
Import an engineering file to compile its SV streams.
+
+ Development profile remains armed + +
`; + microcopy.after(bridge); + + $("streamSelect").addEventListener("change", () => { + profileBridge.selectedIndex = Number($("streamSelect").value); + profileBridge.deployed = false; + renderProfileSelection(); + }); + $("validateProfileButton").addEventListener("click", validateCounterPolicy); + $("deployProfileButton").addEventListener("click", deploySelectedProfile); + $("currentScale").addEventListener("change", updateScaling); + $("voltageScale").addEventListener("change", updateScaling); + + els.sclFile.addEventListener("change", async () => { + const file = els.sclFile.files?.[0]; + if (!file) return; + profileBridge.file = file; + profileBridge.deployed = false; + await inspectEngineeringFile(null); + }); + + // Replace the development-only conversion with explicit profile-level test scaling. + wireCountFor = function(channel) { + const scale = channel.kind === "current" + ? profileBridge.currentCountsPerAmp + : profileBridge.voltageCountsPerVolt; + return Math.round(channel.magnitude * scale); + }; + + const baseSetConnected = setConnected; + setConnected = function(connected) { + baseSetConnected(connected); + refreshDeployAvailability(); + }; + const baseSetRunning = setRunning; + setRunning = function(running) { + baseSetRunning(running); + refreshDeployAvailability(); + }; + const baseProcessDeviceLine = processDeviceLine; + processDeviceLine = function(raw) { + baseProcessDeviceLine(raw); + const line = raw.replace(/\x1b\[[0-9;]*m/g, "").trim(); + const committed = line.match(/PROFILE committed generation=(\d+)\s+svID=(\S+)\s+APPID=0x([0-9A-Fa-f]+)\s+rate=(\d+)\s+wrap=(\d+)/); + if (committed && profileBridge.deploying) { + profileBridge.deploying = false; + profileBridge.deployed = true; + applySelectedProfileToActiveCard(); + $("deployState").textContent = `Profile armed · generation ${committed[1]}`; + showToast("SCL profile deployed and armed"); + refreshDeployAvailability(); + } + const armed = line.match(/PROFILE armed generation=(\d+)\s+svID=(\S+)\s+APPID=0x([0-9A-Fa-f]+)\s+rate=(\d+)\s+wrap=(\d+)/); + if (armed) { + $("deployState").textContent = `Running profile · generation ${armed[1]}`; + } + if (line.includes("PROFILE commit rejected") || line.includes("PROFILE rejected")) { + profileBridge.deploying = false; + showToast("Device rejected the profile", true); + refreshDeployAvailability(); + } + }; +} + +function updateScaling() { + const current = Number($("currentScale").value); + const voltage = Number($("voltageScale").value); + if (Number.isFinite(current) && current > 0) profileBridge.currentCountsPerAmp = current; + if (Number.isFinite(voltage) && voltage > 0) profileBridge.voltageCountsPerVolt = voltage; + buildChannels(); + drawPhasors(); +} + +async function inspectEngineeringFile(counterModulus) { + const file = profileBridge.file; + if (!file) return; + els.sclState.textContent = `${file.name} · compiling…`; + $("bridgeMessages").textContent = "Running smart IEC 61850 profile compiler…"; + try { + const query = counterModulus ? `?counterModulus=${encodeURIComponent(counterModulus)}` : ""; + const response = await fetch(`/api/scl/inspect${query}`, { + method: "POST", + headers: { "Content-Type": "application/xml", "X-File-Name": file.name }, + body: await file.arrayBuffer(), + }); + const payload = await response.json(); + if (payload.fatalError) throw new Error(payload.fatalError); + profileBridge.inspection = payload; + if (!payload.streams?.length) throw new Error("No Sampled Values streams were resolved by the IEC 61850 engine."); + if (!payload.streams.some((stream) => stream.index === profileBridge.selectedIndex)) { + profileBridge.selectedIndex = payload.streams[0].index; + } + populateStreamSelector(); + els.sclState.textContent = `${file.name} · ${payload.streams.length} compiled SV stream${payload.streams.length === 1 ? "" : "s"}`; + renderProfileSelection(); + showToast("Engineering file compiled by the IEC 61850 engine"); + } catch (error) { + profileBridge.inspection = null; + els.sclState.textContent = `${file.name} · rejected`; + $("bridgeMessages").textContent = error.message; + $("compatBadge").textContent = "BLOCKED"; + $("compatBadge").dataset.class = "C"; + showToast(error.message, true); + refreshDeployAvailability(); + } +} + +function populateStreamSelector() { + const select = $("streamSelect"); + select.innerHTML = ""; + for (const stream of profileBridge.inspection.streams) { + const option = document.createElement("option"); + option.value = String(stream.index); + option.textContent = `${stream.ied || "IED"} · ${stream.control || stream.profile?.svID || `SV ${stream.index + 1}`}`; + option.selected = stream.index === profileBridge.selectedIndex; + select.appendChild(option); + } + select.disabled = false; +} + +function renderProfileSelection() { + const stream = selectedCompiledStream(); + const p = stream?.profile; + if (!stream || !p) { + $("bridgeTitle").textContent = "Stream cannot be compiled"; + $("bridgeMessages").textContent = [...(stream?.errors ?? []), ...(stream?.warnings ?? [])].join(" · ") || "Unresolved profile"; + $("compatBadge").textContent = "CLASS C"; + $("compatBadge").dataset.class = "C"; + $("counterConfirm").hidden = true; + $("scalingRow").hidden = true; + refreshDeployAvailability(); + return; + } + + $("bridgeTitle").textContent = p.svID || stream.controlBlockReference || "SV profile"; + $("compatBadge").textContent = `CLASS ${stream.compatibilityClass}`; + $("compatBadge").dataset.class = stream.compatibilityClass; + $("bridgeMac").textContent = p.destinationMac; + $("bridgeAppid").textContent = `0x${Number(p.appID).toString(16).toUpperCase().padStart(4, "0")}`; + $("bridgeRate").textContent = p.publisherRateHz ? `${p.publisherRateHz} fps` : `${p.sampleRate} ${p.sampleMode}`; + $("bridgePayload").textContent = `${p.payloadBytes} B · ${p.channels.length} leaves`; + $("bridgeSupport").textContent = stream.deviceSupport; + $("counterConfirm").hidden = stream.compatibilityClass === "A"; + $("scalingRow").hidden = false; + if (stream.compatibilityClass !== "A" && p.counterModulus) { + $("counterModulus").value = String(p.counterModulus); + $("counterConfirmed").checked = false; + } + const messages = [...(stream.errors ?? []), ...(stream.warnings ?? [])]; + if (profileBridge.inspection.warnings?.length) messages.push(...profileBridge.inspection.warnings); + if (profileBridge.inspection.conflicts?.length) { + messages.push(...profileBridge.inspection.conflicts.map((c) => `Conflict: ${c.description}`)); + } + $("bridgeMessages").textContent = messages.join(" · ") || "Profile semantics resolved."; + $("deployState").textContent = profileBridge.deployed ? "Profile armed on device" : "Not deployed"; + refreshDeployAvailability(); +} + +async function validateCounterPolicy() { + const value = Number($("counterModulus").value); + if (!$("counterConfirmed").checked) { + showToast("Confirm the counter cycle before validation", true); + return; + } + if (!Number.isInteger(value) || value <= 0 || value > 65535) { + showToast("Counter modulus must be 1..65535", true); + return; + } + await inspectEngineeringFile(value); +} + +function refreshDeployAvailability() { + const button = $("deployProfileButton"); + if (!button) return; + const stream = selectedCompiledStream(); + const ready = Boolean( + stream?.profile && + stream.compatibilityClass === "A" && + stream.deviceSupport === "ready" && + state.connected && !state.running && !profileBridge.deploying + ); + button.disabled = !ready; + if (profileBridge.file && !profileBridge.deployed) { + els.startButton.disabled = true; + } else if (state.connected && !state.running) { + els.startButton.disabled = false; + } +} + +async function deploySelectedProfile() { + const stream = selectedCompiledStream(); + const p = stream?.profile; + if (!p || stream.compatibilityClass !== "A" || stream.deviceSupport !== "ready") { + showToast("Profile is not deployable yet", true); + return; + } + if (state.running) { + showToast("Stop the publisher before changing profile identity/layout", true); + return; + } + + const idHex = utf8Hex(p.svID); + const dataSetHex = p.asduOptions?.dataSet ? utf8Hex(p.dataSetReference) : "-"; + if (!idHex || idHex.length > 180 || dataSetHex.length > 170) { + showToast("Profile identifier is too long for the current device bridge", true); + return; + } + const mac = macHex(p.destinationMac); + if (mac.length !== 12) { + showToast("Invalid destination MAC in compiled profile", true); + return; + } + let flags = 0; + if (p.asduOptions?.dataSet) flags |= 1; + if (p.asduOptions?.sampleRate) flags |= 2; + + profileBridge.deploying = true; + profileBridge.deployed = false; + $("deployState").textContent = "Deploying validated profile…"; + refreshDeployAvailability(); + + const commands = [ + "PROFILE BEGIN", + `PROFILE ID ${idHex}`, + `PROFILE DATASET ${dataSetHex}`, + `PROFILE L2 ${p.appID} ${mac} ${p.vlanPresent ? 1 : 0} ${p.vlanID || 0} ${p.vlanPriority || 0}`, + `PROFILE SV ${p.confRev} ${p.publisherRateHz} ${p.counterModulus} ${p.nofASDU} ${flags}`, + "PROFILE COMMIT", + "PROFILE SHOW", + ]; + for (const command of commands) { + const sent = await sendCommand(command); + if (!sent) { + profileBridge.deploying = false; + refreshDeployAvailability(); + return; + } + } +} + +function applySelectedProfileToActiveCard() { + const stream = selectedCompiledStream(); + const p = stream?.profile; + if (!p) return; + $("profileBadge").textContent = "SCL profile armed"; + $("svIdValue").textContent = p.svID; + $("appIdValue").textContent = `0x${Number(p.appID).toString(16).toUpperCase().padStart(4, "0")}`; + $("sampleRateValue").textContent = `${p.publisherRateHz} fps`; + $("counterValue").textContent = `wrap ${p.counterModulus}`; + $("vlanValue").textContent = p.vlanPresent ? `PCP ${p.vlanPriority} · VID ${p.vlanID}` : "untagged"; +} + +installProfileUi(); From 55030c04754a5e6ced00d92ed12771a54cb41b86 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:07:12 +0700 Subject: [PATCH 17/36] style(gui): add SCL profile compiler and deploy states --- apps/smv_injector_gui/profile_bridge.css | 67 ++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 apps/smv_injector_gui/profile_bridge.css diff --git a/apps/smv_injector_gui/profile_bridge.css b/apps/smv_injector_gui/profile_bridge.css new file mode 100644 index 000000000..fba5cad40 --- /dev/null +++ b/apps/smv_injector_gui/profile_bridge.css @@ -0,0 +1,67 @@ +.profile-bridge-card { + margin-top: 16px; + padding: 15px; + border: 1px solid rgba(79,199,255,.18); + border-radius: 14px; + background: rgba(4,12,18,.34); +} +.bridge-heading, .bridge-actions, .counter-confirm, .scaling-row { + display: flex; + align-items: center; +} +.bridge-heading { justify-content: space-between; gap: 14px; } +.bridge-heading strong { display:block; margin-top:3px; font-size:14px; } +.compat-badge { + min-width: 66px; padding: 6px 10px; border-radius: 999px; text-align:center; + font-size:10px; font-weight:800; letter-spacing:.06em; border:1px solid var(--line); + color:var(--muted); background:rgba(255,255,255,.025); +} +.compat-badge[data-class="A"] { color:#bdf4dd; border-color:rgba(61,220,151,.3); background:rgba(61,220,151,.09); } +.compat-badge[data-class="B"] { color:#ffe2a2; border-color:rgba(242,187,76,.3); background:rgba(242,187,76,.08); } +.compat-badge[data-class="C"] { color:#ffc4c9; border-color:rgba(255,107,118,.3); background:rgba(255,107,118,.08); } +.bridge-grid { + margin-top:13px; display:grid; grid-template-columns:1.5fr repeat(5, minmax(95px,1fr)); gap:8px; +} +.bridge-field { + min-height:58px; padding:9px 10px; border:1px solid var(--line); border-radius:10px; + background:rgba(3,10,15,.35); overflow:hidden; +} +.bridge-field span { display:block; color:var(--muted); font-size:9px; text-transform:uppercase; letter-spacing:.09em; } +.bridge-field strong { display:block; margin-top:5px; font-size:11px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.bridge-field select { + width:100%; margin-top:5px; border:0; outline:0; color:var(--text); background:#0a1721; + font-size:11px; font-weight:650; +} +.counter-confirm { + margin-top:10px; gap:10px; flex-wrap:wrap; padding:10px; border-radius:10px; + border:1px solid rgba(242,187,76,.18); background:rgba(242,187,76,.045); +} +.counter-confirm > div { min-width:220px; flex:1; } +.counter-confirm span { display:block; color:#e8c979; font-size:10px; font-weight:750; } +.counter-confirm strong { display:block; margin-top:3px; color:var(--muted); font-size:9px; font-weight:500; } +.counter-confirm input[type="number"] { + width:100px; height:34px; border:1px solid var(--line); border-radius:8px; background:#071119; color:var(--text); padding:0 8px; +} +.counter-confirm label { color:#cbd8e1; font-size:10px; } +.scaling-row { + margin-top:10px; gap:10px; flex-wrap:wrap; padding:9px 10px; border:1px solid var(--line); border-radius:10px; +} +.scaling-note { min-width:230px; flex:1; color:var(--muted); font-size:9px; line-height:1.4; } +.scaling-row label { display:flex; align-items:center; gap:5px; color:#cbd8e1; font-size:9px; } +.scaling-row input { width:78px; height:30px; border:1px solid var(--line); border-radius:7px; background:#071119; color:var(--text); padding:0 7px; } +.scaling-row span { color:var(--muted); } +.bridge-messages { + margin-top:10px; color:#8199aa; font-size:9px; line-height:1.45; max-height:58px; overflow:auto; +} +.bridge-actions { justify-content:space-between; gap:10px; margin-top:10px; } +.bridge-actions span { color:var(--muted); font-size:10px; } +.bridge-actions .btn { min-width:150px; } + +@media (max-width: 1360px) { + .bridge-grid { grid-template-columns: repeat(3, minmax(120px,1fr)); } + .bridge-stream { grid-column: span 2; } +} +@media (max-width: 900px) { + .bridge-grid { grid-template-columns: 1fr 1fr; } + .bridge-stream { grid-column: 1 / -1; } +} From 5549459fa0f444772d6b2bfd475eb343434722d5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:07:37 +0700 Subject: [PATCH 18/36] feat(gui): load smart SCL profile bridge --- apps/smv_injector_gui/index.html | 88 ++++++-------------------------- 1 file changed, 16 insertions(+), 72 deletions(-) diff --git a/apps/smv_injector_gui/index.html b/apps/smv_injector_gui/index.html index 9754aa900..1c2362f13 100644 --- a/apps/smv_injector_gui/index.html +++ b/apps/smv_injector_gui/index.html @@ -7,25 +7,17 @@ ARStack61850 · SMV Injector +
- -
-
ARStack61850
-

SMV Injector

-
+ +
ARStack61850

SMV Injector

-
-
- - Device offline -
+
Device offline
@@ -34,18 +26,11 @@

SMV Injector

-
-
Process bus stream
-

Active publisher profile

-
+
Process bus stream

Active publisher profile

Development profile
-
-
- svID - MU01_SV1 -
+
svIDMU01_SV1
APPID0x4000
Rate4000 fps
@@ -53,55 +38,32 @@

Active publisher profile

VLANPCP 4 · VID 0
-
- +
No engineering file loaded
-

Engineering files are loaded locally. Runtime profile deployment will connect to the host profile compiler; live signal control below already talks directly to the ESP32-P4 bench firmware.

+

Engineering files are compiled by the local IEC 61850 profile engine before deployment.

-
- -
-
Publisher state
- STOPPED -
-
- +
Publisher state
STOPPED
Observed rate—
Missed slots—
TX failures—
Signal generation—
- -
- - -
+
-
-
Signal frequency
-
Hz
-
+
Signal frequency
Hz
-
- + @@ -109,42 +71,24 @@

Active publisher profile

-
-
-
Live signal control
-

Current & voltage channels

-
-

Magnitude uses engineering units. Phase uses ordinary degrees. Conversion to wire counts and milli-degrees happens inside the GUI.

-
+
Live signal control

Current & voltage channels

Magnitude uses engineering units. Phase uses ordinary degrees. Conversion to wire values remains outside the realtime publisher.

-
-
-
Vector preview
-

Phase relationships

-
-
+
Vector preview

Phase relationships

-
-
-
-
Device evidence
-

Runtime log

-
- -
+
Device evidence

Runtime log

Waiting for device connection…
-
+ From 18b090806b59d8902a15ed89fb9ccbc5bc5efe84 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:08:19 +0700 Subject: [PATCH 19/36] ci(gui): validate smart SCL profile bridge --- .github/workflows/smv-injector-gui.yml | 55 +++++++++++++++++++++----- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/.github/workflows/smv-injector-gui.yml b/.github/workflows/smv-injector-gui.yml index f414f17ba..7f2c9b743 100644 --- a/.github/workflows/smv-injector-gui.yml +++ b/.github/workflows/smv-injector-gui.yml @@ -4,17 +4,27 @@ on: push: paths: - 'apps/smv_injector_gui/**' + - 'tools/smv_profile_inspect.cpp' + - 'include/ariec61850/sampled_values/publisher_profile.hpp' + - 'include/ariec61850/scl/**' + - 'src/scl/**' + - 'tests/fixtures/scl/**' - '.github/workflows/smv-injector-gui.yml' pull_request: paths: - 'apps/smv_injector_gui/**' + - 'tools/smv_profile_inspect.cpp' + - 'include/ariec61850/sampled_values/publisher_profile.hpp' + - 'include/ariec61850/scl/**' + - 'src/scl/**' + - 'tests/fixtures/scl/**' - '.github/workflows/smv-injector-gui.yml' permissions: contents: read jobs: - static-smoke: + profile-bridge-smoke: runs-on: ubuntu-latest steps: - name: Checkout @@ -26,28 +36,53 @@ jobs: node-version: '22' - name: JavaScript syntax - run: node --check apps/smv_injector_gui/app.js + run: | + node --check apps/smv_injector_gui/app.js + node --check apps/smv_injector_gui/profile_bridge.js - name: Required UI files shell: bash run: | test -s apps/smv_injector_gui/index.html test -s apps/smv_injector_gui/styles.css + test -s apps/smv_injector_gui/profile_bridge.css test -s apps/smv_injector_gui/app.js + test -s apps/smv_injector_gui/profile_bridge.js + test -s apps/smv_injector_gui/host_server.py test -s apps/smv_injector_gui/run.cmd - grep -q 'SMV Injector' apps/smv_injector_gui/index.html grep -q 'navigator.serial' apps/smv_injector_gui/app.js + grep -q '/api/scl/inspect' apps/smv_injector_gui/profile_bridge.js + + - name: Build smart SCL profile engine + shell: bash + run: | + cmake -S apps/smv_injector_gui/host -B build-smv-gui -G Ninja -DCMAKE_BUILD_TYPE=Release + cmake --build build-smv-gui --target ariec61850_smv_profile_inspect + + - name: Profile compiler regression + shell: bash + run: | + 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 '"publisherRateHz":4800' /tmp/profile-b.json + build-smv-gui/ariec61850_smv_profile_inspect "$fixture" --counter-modulus 4800 > /tmp/profile-a.json + grep -q '"compatibilityClass":"A"' /tmp/profile-a.json + grep -q '"deviceSupport":"ready"' /tmp/profile-a.json - - name: Local-server smoke + - name: Local backend smoke shell: bash run: | - python3 -m http.server 8765 --bind 127.0.0.1 --directory apps/smv_injector_gui >/tmp/smv-gui-http.log 2>&1 & + python3 apps/smv_injector_gui/host_server.py --profile-tool build-smv-gui/ariec61850_smv_profile_inspect --port 8765 >/tmp/smv-gui-http.log 2>&1 & server_pid=$! trap 'kill ${server_pid} 2>/dev/null || true' EXIT - for attempt in $(seq 1 20); do - if curl --fail --silent http://127.0.0.1:8765/ >/tmp/smv-gui-index.html; then - break - fi - sleep 0.25 + for attempt in $(seq 1 30); do + if curl --fail --silent http://127.0.0.1:8765/ >/tmp/smv-gui-index.html; then break; fi + sleep 0.2 done grep -q 'ARStack61850' /tmp/smv-gui-index.html + 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 + grep -q '"deviceSupport":"ready"' /tmp/api.json From ff66b64ba84c7245984923bd86040e4528ad34cd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:10:17 +0700 Subject: [PATCH 20/36] docs(gui): document SCL compile and device deploy workflow --- apps/smv_injector_gui/README.md | 137 +++++++++++++++----------------- 1 file changed, 65 insertions(+), 72 deletions(-) diff --git a/apps/smv_injector_gui/README.md b/apps/smv_injector_gui/README.md index cf498575e..eec90e23f 100644 --- a/apps/smv_injector_gui/README.md +++ b/apps/smv_injector_gui/README.md @@ -1,96 +1,89 @@ # ARStack61850 SMV Injector GUI -This is the first operator-facing control surface for the ESP32-P4 Sampled Values injector runtime. - -It intentionally replaces direct bench command entry with user-facing engineering controls while keeping the current firmware protocol unchanged underneath. - -## What works now - -- connect directly to the ESP32-P4 serial control port; -- START and STOP the deterministic publisher; -- edit Ia, Ib, Ic, In, Ua, Ub, Uc and Un while the stream is running; -- use engineering units instead of raw firmware units: - - current: A RMS -> current wire counts for the current development profile; - - voltage: V RMS -> voltage wire counts for the current development profile; - - phase: degrees -> milli-degrees; - - frequency: Hz -> millihertz; -- enable/disable channels; -- zero all outputs; -- restore a balanced three-phase bench state; -- live-apply values as the operator edits them, or pause live apply and use Apply/Apply all; -- display a local phasor preview; -- parse device telemetry for publisher rate, missed slots, failures and signal generation; -- load an SCL/CID/SCD/IID file locally and confirm that it is well-formed SCL plus count discovered `SampledValueControl` elements. - -The SCL file picker is deliberately not a second IEC 61850 semantic engine. Full engineering-file normalization and `SvPublisherProfile` compilation remain in the C++ host engine and will be connected to this GUI through the P3-A1 device-profile bridge. +Operator-facing control plane for the ESP32-P4 Sampled Values injector. -## Run +The GUI is not a waveform generator. The PC compiles engineering intent and sends bounded control/profile updates; the ESP32-P4 remains the realtime publisher. -1. Flash the current `feature/sv-live-control-runtime` compatible firmware (the GUI branch is based on that runtime). -2. Exit `idf.py monitor` with `Ctrl+]`. The serial port cannot be owned by the monitor and GUI at the same time. -3. From the repository root, run: +## Current workflow -```powershell -.\apps\smv_injector_gui\run.cmd +```text +Import SCL / CID / SCD / IID + -> C++ SCL parser + -> normalized SvPublisherProfile + -> select SV stream + -> resolve warnings / counter policy + -> configure test scaling when SCL does not establish it + -> Deploy to device while STOPPED + -> Start + -> live I/U/phase/frequency/quality changes + -> Stop ``` -4. The launcher opens the local GUI and starts an HTTP server bound only to `127.0.0.1`. -5. Click **Connect device** and select the ESP32-P4 serial port. -6. Use **Start**, edit channel magnitude/phase/frequency, and observe the live stream. +The browser does not implement a second IEC 61850 semantic parser. `run.cmd` builds the focused C++ profile compiler and starts a localhost-only Python bridge. Uploaded engineering data is written to a temporary local file only for compilation and removed immediately after the request. -The browser must expose the Web Serial API. The GUI is served from localhost because device serial APIs require a secure local context. +## Supported device profile in this phase -## Important unit boundary +The host parser can inspect broader SCL SV structures, but the first ESP32-P4 deployment bridge intentionally accepts only the physically proven 4I+4V wire layout: -The firmware bench protocol still carries raw units. The GUI hides those details from the operator: +- one ASDU; +- `SmpPerSec` with an absolute publisher rate; +- explicit/validated sample-counter modulus; +- 16 ordered leaves as 8 `INT32 value + Quality` pairs; +- 64-byte sample payload; +- configurable destination MAC, APPID, VLAN/PCP, svID, confRev and supported ASDU field presence. -```text -GUI current A RMS -> SET ... current_counts ... -GUI voltage V RMS -> SET ... voltage_counts ... -GUI phase deg -> SET ... phase_mdeg ... -GUI frequency Hz -> FREQ frequency_millihz -``` +A structurally valid but unsupported layout remains visible in the GUI and is blocked from deployment. The firmware does not guess a payload layout. -This means an operator can enter `-120` degrees and the GUI sends `-120000` milli-degrees. The earlier confusing direct-console behavior is no longer part of the normal operator workflow. +## Counter-policy rule -The current A/V conversion is explicitly tied to the development profile that is still hard-coded in the firmware. When the compiled SCL profile bridge lands, the GUI must obtain engineering-unit scaling from the resolved profile rather than assume a universal scale. +SCL sampling rate does not universally prove the `smpCnt` wrap policy. When the compiler can only infer a rate-sized candidate, the GUI shows Class B and requires explicit confirmation from an applicable profile rule, independent wire evidence, or engineering knowledge before deployment becomes Class A. -## Realtime rule +## Engineering scaling -The GUI is control plane only. It does not synthesize the 4 kHz waveform and does not become a timing dependency. +Generic SCL does not always establish physical scaling. The GUI therefore exposes current and voltage counts-per-unit conversion for the active test profile instead of claiming a universal scale. This scaling affects only engineering-value conversion in the control plane; it does not change realtime scheduling. -```text -GUI / host - | - | control updates - v -ESP32-P4 live state bank - | - v -fixed-point signal engine - | - v -prebuilt SV packet template - | - v -realtime Ethernet publisher +## Run + +1. Flash firmware from the same `feature/smv-scl-profile-bridge` branch. +2. Exit `idf.py monitor` with `Ctrl+]`; the GUI and monitor cannot own the serial port simultaneously. +3. From the repository root run: + +```powershell +.\apps\smv_injector_gui\run.cmd ``` -Disconnecting or closing the GUI must not make the PC responsible for sample timing. The embedded publisher remains the realtime authority. +The launcher incrementally builds the smart SCL profile tool into `build-smv-gui`, starts the local control-plane server on `127.0.0.1:8765`, and opens the GUI. -## Next integration +4. Click **Connect device** and select the ESP32-P4 serial port. +5. Import an engineering file, select an SV stream, resolve any Class-B counter-policy requirement, then **Deploy to device** while STOPPED. +6. Press **Start**. Magnitude, phase, frequency, quality and enabled state remain live controls while running. -The next product step is to connect the C++ SCL profile compiler to this surface so the workflow becomes: +## Realtime boundary ```text -Import SCL/CID - -> select SV stream - -> validate normalized profile - -> deploy immutable stream identity/layout - -> edit live engineering values - -> Start - -> change values while running - -> Stop +engineering file + | + v +C++ profile compiler ---- GUI engineering values + | | + +-----------+---------------+ + | + v + ESP32-P4 control state + | + immutable wire profile + + mutable signal state + | + v + fixed-point signal engine + | + v + prebuilt SV packet template + | + v + realtime Ethernet publisher ``` -Stream identity/layout changes remain a STOP -> validate -> arm operation. Magnitude, phase, frequency, quality and enabled state are live controls. +Profile identity/layout changes require `STOP -> validate -> deploy -> arm`. Live signal values do not rebuild BER and do not restart `smpCnt`. + +The current rational GPTimer scheduler supports runtime publisher rates without rounding a fractional-microsecond period into a permanently wrong fixed interval; for example, 4800 events/s uses a repeating 208/208/209 microsecond schedule at the 1 MHz timer domain. From 366aa56c27007f92de6fe53d0bb8575536fa0f4e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:14:27 +0700 Subject: [PATCH 21/36] docs: keep embedded roadmap vendor-neutral --- ESP32_EMBEDDED_ROADMAP.md | 165 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 ESP32_EMBEDDED_ROADMAP.md diff --git a/ESP32_EMBEDDED_ROADMAP.md b/ESP32_EMBEDDED_ROADMAP.md new file mode 100644 index 000000000..ff07db047 --- /dev/null +++ b/ESP32_EMBEDDED_ROADMAP.md @@ -0,0 +1,165 @@ +# ARStack61850 Embedded Runtime Roadmap + +## Product direction + +ARStack61850 is a portable IEC 61850 instrumentation stack with two complementary deployment classes: + +1. **Host / engineering** — Windows and Linux tools for SCL engineering, live discovery, evidence, diagnostics, waveform/scenario configuration, capture integration, and interoperability workflows. +2. **Embedded / realtime runtime** — bounded protocol execution on dedicated hardware for deterministic SV/GOOSE publication, capture, timing evidence, and future synchronized operation. + +The host is the engineering brain. Dedicated hardware is the realtime packet and timing authority. + +## Process-bus hardware principles + +The embedded process-bus path requires a real Ethernet Layer-2 interface. Wireless networking is not a substitute for canonical SV/GOOSE transmission. + +Reference platforms should provide, as applicable: + +- raw Ethernet frame transmission and reception; +- deterministic interrupt/DMA behavior; +- hardware-assisted timestamps where available; +- sufficient RAM for bounded packet/profile/capture buffers; +- a management/control path that does not become the sample-timing source; +- electrical isolation appropriate to the intended laboratory or field role. + +Board-specific pin maps, commercial product identities, and marketing specifications do not belong in the public architecture roadmap. Platform adapters keep those details outside the protocol core. + +## Non-negotiable architecture rules + +### Embedded core + +The embedded core may contain wire codecs, immutable compiled profiles, bounded state machines, and realtime signal/runtime primitives, but it must not depend on: + +- desktop socket APIs; +- filesystem or interactive CLI workflows; +- full XML/SCL parsing; +- PCAP/evidence report generation; +- desktop UI frameworks; +- host-only discovery and engineering workflows. + +Platform ownership is injected through small HAL/adaptor contracts. RTOS, MAC/PHY, DMA, timestamp, USB/management, and board details remain outside the IEC 61850 semantic core. + +### SCL/profile boundary + +XML and vendor-specific engineering-file dialect handling stay on the host: + +```text +SCL / CID / SCD / IID + | + v +smart tolerant host parser + | + v +validated immutable profile + | + v +embedded runtime +``` + +The host may normalize representation differences, preserve provenance, quarantine irrelevant extensions, and explain conflicts. Deployment remains fail-closed when mandatory wire semantics are unresolved. + +### Realtime boundary + +The realtime publisher must not parse XML, rebuild BER per sample, allocate on the hot path, or depend on Windows/Linux scheduling. + +Preferred execution model: + +```text +validated profile + mutable signal state + | + v + prebuilt packet template + | + v + deterministic scheduler + | + v + Ethernet MAC/DMA + | + v + timing evidence +``` + +Signal values may change coherently while running. Stream identity/layout changes require STOP -> validate -> arm. + +### Memory discipline + +Steady-state publisher/subscriber paths should use: + +- preallocated or caller-owned buffers; +- bounded capacities; +- no per-frame heap allocation; +- no unbounded queues; +- explicit overflow/drop accounting; +- fixed or bounded metadata structures; +- predictable CPU ownership. + +Host convenience models may use dynamic containers at configuration time as long as they do not leak allocation requirements into the realtime path. + +## Timing truth hierarchy + +Timing claims should progress through increasingly authoritative evidence: + +1. sample deadline; +2. realtime task wake; +3. MAC transmit/receive hardware timestamp where supported; +4. independent measurement-point timestamp. + +Host arrival time is useful diagnostic information but must not be treated as canonical wire timing evidence. + +## Synchronization direction + +A future synchronized runtime should separate: + +- wire protocol parsing; +- passive synchronization monitoring; +- applicable profile validation; +- hardware clock access; +- offset/delay estimation and servo; +- lock / acquiring / holdover states; +- sample-synchronization field policy; +- timestamp evidence. + +Presence of synchronization traffic alone is never proof of lock. + +## Process-bus capture direction + +The receive side should converge on the same realtime/evidence core used by the publisher: + +- promiscuous raw receive; +- hardware timestamps where available; +- bounded capture rings; +- sequence/gap/duplicate/out-of-order accounting; +- SCL expected-vs-observed comparison; +- waveform, RMS, frequency, and phasor reconstruction; +- triggers with bounded pre/post capture; +- evidence export handled by the host. + +## Hardware evolution + +The current single-port MCU-class reference platform is suitable for proving deterministic publication, receive instrumentation, control-plane separation, and timestamp architecture. + +A later precision platform should be specified from measured needs rather than assumptions. Potential requirements include: + +- multiple independent process-side physical paths; +- separate management path; +- copper and/or optical interfaces as required by deployment; +- timestamping close to the measurement point; +- disciplined oscillator/clock architecture; +- external switching, tapping, or programmable logic only where measured throughput/timing requirements justify it. + +## Delivery order + +1. deterministic SV publisher foundation; +2. hardware timing evidence; +3. smart SCL-to-profile compiler; +4. immutable device-profile deployment; +5. live engineering-value control through the GUI; +6. broad interoperability matrix and observed-wire resolver; +7. synchronized clock/profile support; +8. hardware-timestamped process-bus analyzer; +9. precision multi-port hardware when evidence requires it. + +## Public repository rule + +Public project-owned content remains vendor-neutral. Research findings from commercial implementations may inform engineering principles privately, but public architecture should contain standards, generic engineering patterns, reproducible measurements, and project-owned evidence rather than competitor/product references or UI imitation. From 1b37afa63ffc157b999de0cea21740877b880fe0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:14:39 +0700 Subject: [PATCH 22/36] docs: remove legacy vendor-specific embedded roadmap --- ESP32_WAVESHARE_ROADMAP.md | 385 ------------------------------------- 1 file changed, 385 deletions(-) delete mode 100644 ESP32_WAVESHARE_ROADMAP.md diff --git a/ESP32_WAVESHARE_ROADMAP.md b/ESP32_WAVESHARE_ROADMAP.md deleted file mode 100644 index 34ff38045..000000000 --- a/ESP32_WAVESHARE_ROADMAP.md +++ /dev/null @@ -1,385 +0,0 @@ -# arstack61850 — Public Alpha + Waveshare ESP32-S3 Roadmap - -## Product direction - -arstack61850 is not intended to become a line-by-line C++ clone of ARIEC61850. -ARIEC61850 remains the behavioral oracle. arstack61850 becomes the portable -industrial implementation with two supported deployment classes: - -1. **Host / engineering** — Windows and Linux tools, live MMS discovery, - evidence, diagnostics, SCL/PCAP/COMTRADE integration. -2. **Embedded / runtime** — bounded protocol engine suitable for ESP32-class - MCUs first and STM32/NXP-class targets later. - -The first reference MCU is the **Waveshare ESP32-S3-POE-ETH-8DI-8DO**. - -## Reference hardware facts - -Board: Waveshare ESP32-S3-POE-ETH-8DI-8DO, SKU 32108. - -- ESP32-S3-WROOM-1U-N16R8: 16 MB Flash, 8 MB PSRAM. -- W5500 10/100 Ethernet controller over SPI. -- PoE module, IEEE 802.3af. -- Eight isolated digital inputs and eight isolated digital outputs. -- W5500 pins: INT=GPIO12, MOSI=13, MISO=14, SCLK=15, CS=16, RST=39. -- DI1..DI8: GPIO4..GPIO11. -- Digital outputs are exposed through TCA9554PWR, I2C address 0x20. -- Isolated RS485 and CAN are available for future gateway use. -- W5500 supports MACRAW at chip level. The preferred ESP-IDF W5500 driver - operates the device in MAC RAW mode and feeds the ESP software TCP/IP stack. - Raw SV/GOOSE and lwIP TCP/MMS therefore share one Ethernet interface instead - of arstack independently allocating W5500 hardware sockets. - -Raw Layer-2 SV/GOOSE is an Ethernet feature. Wi-Fi is not a substitute for the -reference process-bus path. - -## Non-negotiable architecture rules - -### Embedded core - -`ARIEC61850::embedded_core` may contain wire codecs and bounded runtime state, -but must not depend on: - -- WinSock or BSD/POSIX sockets; -- filesystem, iostream or CLI code; -- PCAP/evidence tooling; -- COMTRADE; -- full SCL parsing; -- live-discovery host workflows. - -Platform ownership is injected through small callback HAL contracts. ESP-IDF, -lwIP, FreeRTOS and W5500 details stay outside the protocol engine. - -### Memory discipline - -Steady-state publisher/subscriber/runtime paths should move toward: - -- caller-owned buffers; -- bounded capacities; -- no per-frame allocation; -- no RTTI; -- eventually no C++ exceptions in the MCU core. - -Existing `std::vector`/`std::string` data models may remain as configuration-time -containers during migration. They must not force allocation on the 4 kHz SV -steady-state encode/transmit path. - -### Compatibility - -Existing host APIs remain source-compatible where practical. Allocation-free -APIs are added underneath host convenience wrappers, so Windows/Linux users do -not lose ergonomics while the same codec becomes MCU-capable. - ---- - -# Delivery plan - -## Track A — Public Alpha - -### A0. Live read-only MMS foundation — CURRENT - -Acceptance: - -- TPKT/COTP/Session/Presentation/ACSE association works against live vendor IED. -- GetNameList domains, variables and DataSets works. -- GetVariableAccessAttributes works. -- Read works. -- DataSet and RCB inventories are preserved. -- Dynamic/unbound RCB state is represented without false failure. -- RCB planner/availability evidence is read-only. -- GCC, Clang and MSVC CI pass. -- ASan/UBSan and fuzz corpus pass. - -### A1. Public discovery/model alpha - -Goal: a third-party engineer can clone the repository and use it without -knowing the C# project. - -Deliverables: - -- stable `live_discover` CLI contract; -- documented JSON/model schema; -- deterministic structural fingerprint separated from runtime evidence; -- TypeTemplates + VariableTypeDiscoveries parity slice; -- same-IED C# versus C++ model comparison fixture; -- examples for association, read, DataSet and RCB inventory; -- README quick-start for Windows/Linux; -- explicit feature matrix: implemented / partial / host-only / embedded-ready. - -Acceptance: - -- clean configure/build/test from a fresh clone; -- at least three repeat live cycles on the current test IED/simulator; -- no diagnostic regression; -- reproducible JSON parity report checked into evidence. - -### A2. Public alpha release - -Goal: tag a release that is genuinely useful even before full ARIEC61850 parity. - -Required public surface: - -- MMS client association + confirmed read services; -- read-only live model discovery; -- DataSet/RCB inspection; -- GOOSE encode/decode/runtime foundation; -- SV encode/decode foundation; -- documented portability boundary; -- examples and CI status. - -Not release-blocking: - -- full MMS file service; -- TLS; -- all dynamic DataSet mutation workflows; -- complete ARIEC61850 host-tool parity; -- full IED server implementation. - ---- - -## Track E — ESP32 reference product - -### E0. Embedded architecture baseline — IN PROGRESS - -Deliverables: - -- standalone `embedded/CMakeLists.txt`; -- `ARIEC61850::embedded_core`; -- no-RTTI host-simulation CI; -- source-boundary audit; -- callback HAL for raw Ethernet, connected TCP and clocks; -- configurable ESP32 small-capacity profile; -- Waveshare board facts/profile. - -Exit gate: - -- GCC and Clang embedded host-simulation builds link every embedded source; -- host-only dependency audit is clean; -- legacy exception debt is measured and cannot silently expand. - -### E1. Allocation-bounded SV encoder — IN PROGRESS - -Goal: the 4 kHz publisher loop does not allocate a new frame buffer per sample. - -API direction: - -```cpp -std::optional SampledValuesFrameCodec::encoded_size(...) noexcept; -wire::EncodeResult SampledValuesFrameCodec::encode_into( - const SampledValuesFrame&, - std::span destination) noexcept; -``` - -Host `encode()` remains as a convenience wrapper. - -Acceptance: - -- bounded encoder is byte-identical to existing C#/C++ golden vectors; -- buffer-too-small returns required size and does not partially report success; -- same caller-owned Ethernet buffer can be reused continuously; -- timestamp serialization has a no-allocation path; -- sanitizer/fuzzer/host regression remains green. - -### E2. ESP-IDF W5500 Ethernet adapter - -Goal: map `embedded::RawEthernetPort` to the official ESP-IDF W5500 Ethernet -MACRAW path without leaking ESP-IDF/W5500 APIs into protocol code. - -Preferred architecture: - -```text -arstack raw SV/GOOSE ----> esp_eth raw Ethernet transmit ----+ - | -MMS TCP <---- lwIP / BSD socket API <---- esp_netif --------+--> ESP-IDF W5500 MACRAW -``` - -The ESP-IDF W5500 driver owns the chip-level MACRAW operation. arstack should -not independently program W5500 hardware sockets when using that driver. - -Responsibilities: - -- initialize the official ESP-IDF W5500 SPI Ethernet driver with board pins; -- attach the driver to `esp_netif`/lwIP for TCP/MMS; -- expose raw-frame transmit callback for GOOSE/SV; -- preserve destination/source MAC, VLAN tag and EtherType exactly; -- expose PHY/link state; -- instrument TX errors, would-block and frame counts. - -Acceptance on host/mock first: - -- adapter-facing policy can be unit-tested with fake transmit backend; -- protocol core does not include ESP-IDF/W5500 headers. - -Acceptance on board: - -- Ethernet obtains link and normal IP traffic works through lwIP; -- raw custom EtherType frame visible in Wireshark; -- VLAN-tagged frame visible without mutation; -- SV EtherType 0x88BA frame visible while normal TCP/IP remains usable. - -### E3. ESP32-S3 SV Publisher hardware proof - -Start with synthetic waveform; do not block on ADC hardware. - -Reference first profile: - -- 50 Hz system; -- 80 samples/cycle = 4,000 sample instants/s; -- one ASDU initially; -- fixed APPID and multicast destination; -- sample counter wraps according to configured rate; -- configuration revision fixed during a run; -- timestamp optional for the first throughput proof, then enabled. - -Acceptance: - -- sustained 4,000 frames/s target or explicitly documented multi-ASDU packing - alternative; -- no steady-state frame-buffer allocation; -- no sample-counter discontinuity generated by publisher logic; -- packet capture decodes as IEC 61850 SV; -- 10-minute soak first, then 1-hour soak; -- CPU usage, heap delta, minimum free heap and TX errors recorded. - -### E4. Board I/O HAL - -Goal: make the Waveshare hardware useful as a real substation I/O prototype. - -Inputs: - -- DI1..DI8 from GPIO4..GPIO11; -- debounce/filter policy separated from raw input state; -- timestamped state-change queue. - -Outputs: - -- TCA9554PWR adapter at I2C address 0x20; -- EXIO1..EXIO8 mapping verified against schematic/vendor demo before hard-coding; -- safe startup state: outputs off unless application explicitly restores state; -- command watchdog/failsafe hooks. - -Acceptance: - -- deterministic read of all eight DIs; -- deterministic set/reset of all eight DOs; -- power-cycle safe-state test; -- no IEC 61850 code knows GPIO/I2C details. - -### E5. Minimal MMS server foundation - -This is required for the board to become an actual IEC 61850 I/O IED. - -Implement a static, bounded server model first. Do not port desktop dynamic -model discovery into the MCU server. - -Minimum server services: - -1. association acceptor; -2. MMS Initiate negotiation; -3. GetNameList; -4. GetVariableAccessAttributes; -5. Read. - -Initial static model proposal: - -```text -ESP32S3IO -└── LD0 - ├── LLN0 - ├── LPHD1 - ├── GGIO1 digital inputs - └── GGIO2 digital outputs/status -``` - -Acceptance: - -- IEDScout can associate; -- IEDScout can browse the static model; -- eight DI values can be read; -- server remains bounded under malformed/fuzzed MMS requests. - -### E6. Simple IEC 61850 I/O IED - -Add controlled output operation only after read-only server browse/read is -stable. - -Sequence: - -- simple Write where standards/model permit; -- IEC 61850 control model required for the exposed DOs; -- interlock/check hooks; -- operation timeout; -- command audit/result status. - -Reporting sequence: - -- static DataSet; -- URCB first; -- data-change report for DI state; -- BRCB later, not a first-I/O-IED requirement. - -Acceptance: - -- IEDScout browse/read/control proof; -- input change generates expected report; -- output command changes physical DO and reflected model state; -- negative tests for invalid control/FC/reference. - -### E7. GOOSE I/O extension - -After raw MAC transport is proven by SV, GOOSE is a natural extension. - -Targets: - -- DI state as GOOSE publisher; -- GOOSE subscriber mapped to selected DO only through application safety policy; -- retransmission schedule and TAL supervision; -- VLAN priority validation. - -### E8. Industrialization / STM32 migration - -ESP32 is the reference accessibility target, not the final certification claim. -The same embedded core should later move to STM32H7/NXP-class hardware. - -Industrialization work: - -- `-fno-exceptions` embedded core; -- replace remaining hot-path dynamic containers with bounded storage/views; -- explicit memory budget report; -- deterministic scheduler/timing hooks; -- watchdog integration; -- stronger time synchronization strategy; -- long-duration soak and fault injection; -- multi-vendor interoperability campaign. - ---- - -# Priority rule - -Until Public Alpha is tagged: - -- approximately **70% effort** goes to public-useful host protocol parity, - documentation and real-IED evidence; -- approximately **30% effort** goes to embedded architecture and the SV hardware - path. - -Exception: embedded changes that prevent architectural debt (allocation-free -wire encoding, HAL boundaries, compile gates) are performed immediately before -new protocol features are ported. - -After Public Alpha: - -- finish E2/E3 hardware SV proof; -- then shift primary development toward E5/E6 MMS server + I/O IED. - -# Definition of success - -The project succeeds when the same protocol implementation can demonstrate all -of these without forks of the wire core: - -1. Windows/Linux engineering client can browse a real IED. -2. ESP32-S3 can publish standards-shaped SV frames over the ESP-IDF W5500 - MACRAW Ethernet interface. -3. ESP32-S3 can expose eight isolated inputs/outputs as a small IEC 61850 IED. -4. A later STM32/NXP port changes platform adapters and capacities, not protocol - semantics. From 94822e77350ddcabfa642cefc29cf33f6a92de43 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:16:44 +0700 Subject: [PATCH 23/36] fix(embedded): fail closed when publisher arm fails --- embedded/esp32p4_smv_injector/main/live_control.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index dea956d9f..683397519 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -293,6 +293,12 @@ void live_control_bind_publisher_task(const TaskHandle_t task) noexcept { g_publisher_task.store(task, std::memory_order_release); } +void live_control_force_stop() noexcept { + g_running.store(false, std::memory_order_release); + g_start_request.store(false, std::memory_order_release); + wake_publisher(); +} + SvLiveSignalState live_signal_snapshot() noexcept { return g_signal_bank.snapshot(); } From ff3688a95f85180af4f1bf070c1931eea0995b1e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:19:22 +0700 Subject: [PATCH 24/36] fix(gui): make C++ SCL engine the only import authority --- apps/smv_injector_gui/scl_bridge_bootstrap.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 apps/smv_injector_gui/scl_bridge_bootstrap.js diff --git a/apps/smv_injector_gui/scl_bridge_bootstrap.js b/apps/smv_injector_gui/scl_bridge_bootstrap.js new file mode 100644 index 000000000..ee2250c94 --- /dev/null +++ b/apps/smv_injector_gui/scl_bridge_bootstrap.js @@ -0,0 +1,12 @@ +"use strict"; + +// app.js predates the smart host profile compiler and installs a lightweight +// browser XML listener on the engineering-file input. Replace that input node +// before the profile bridge binds so vendor-dialect handling has exactly one +// semantic authority: the C++ SCL engine. This avoids a stricter/shallower DOM +// pre-check disagreeing with the tolerant normalized parser. +if (els.sclFile) { + const freshInput = els.sclFile.cloneNode(true); + els.sclFile.replaceWith(freshInput); + els.sclFile = freshInput; +} From 61ad066e524bc00b087b1ead3b3880e061f525cb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:19:47 +0700 Subject: [PATCH 25/36] fix(gui): route SCL import only through smart profile engine --- apps/smv_injector_gui/index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/smv_injector_gui/index.html b/apps/smv_injector_gui/index.html index 1c2362f13..a0979df64 100644 --- a/apps/smv_injector_gui/index.html +++ b/apps/smv_injector_gui/index.html @@ -89,6 +89,7 @@
+ From 1377f05576db822430d3765c248b4f13be4bd266 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 11:20:09 +0700 Subject: [PATCH 26/36] ci(gui): cover smart SCL import bootstrap --- .github/workflows/smv-injector-gui.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/smv-injector-gui.yml b/.github/workflows/smv-injector-gui.yml index 7f2c9b743..86b9a5215 100644 --- a/.github/workflows/smv-injector-gui.yml +++ b/.github/workflows/smv-injector-gui.yml @@ -38,6 +38,7 @@ jobs: - name: JavaScript syntax run: | node --check apps/smv_injector_gui/app.js + node --check apps/smv_injector_gui/scl_bridge_bootstrap.js node --check apps/smv_injector_gui/profile_bridge.js - name: Required UI files @@ -47,6 +48,7 @@ jobs: test -s apps/smv_injector_gui/styles.css test -s apps/smv_injector_gui/profile_bridge.css test -s apps/smv_injector_gui/app.js + test -s apps/smv_injector_gui/scl_bridge_bootstrap.js test -s apps/smv_injector_gui/profile_bridge.js test -s apps/smv_injector_gui/host_server.py test -s apps/smv_injector_gui/run.cmd From f54ddf8346da299048d2b6d7284a1baa4fd3dbfc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 13:47:19 +0700 Subject: [PATCH 27/36] fix(gui): select native Windows host compiler from ESP-IDF shell --- apps/smv_injector_gui/run.cmd | 101 ++++++++++++++++++++++++++++++++-- 1 file changed, 97 insertions(+), 4 deletions(-) diff --git a/apps/smv_injector_gui/run.cmd b/apps/smv_injector_gui/run.cmd index 992496e12..fdf85eec4 100644 --- a/apps/smv_injector_gui/run.cmd +++ b/apps/smv_injector_gui/run.cmd @@ -1,8 +1,12 @@ @echo off -setlocal +setlocal EnableExtensions set "PORT=8765" +set "BUILD_ONLY=0" +if /I "%~1"=="--build-only" set "BUILD_ONLY=1" + set "APP_DIR=%~dp0" -for %%I in ("%APP_DIR%..\..\..") do set "REPO_ROOT=%%~fI" +rem apps\smv_injector_gui -> repository root is two levels up. +for %%I in ("%APP_DIR%..\..") do set "REPO_ROOT=%%~fI" set "BUILD_DIR=%REPO_ROOT%\build-smv-gui" set "PROFILE_TOOL=%BUILD_DIR%\ariec61850_smv_profile_inspect.exe" @@ -17,7 +21,7 @@ if errorlevel 1 ( where cmake >nul 2>nul if errorlevel 1 ( echo CMake was not found on PATH. - echo Run this launcher from the ESP-IDF shell. + echo Run this launcher from the ESP-IDF shell or another shell with CMake available. pause exit /b 1 ) @@ -25,7 +29,7 @@ if errorlevel 1 ( where ninja >nul 2>nul if errorlevel 1 ( echo Ninja was not found on PATH. - echo Run this launcher from the ESP-IDF shell. + echo Run this launcher from the ESP-IDF shell or another shell with Ninja available. pause exit /b 1 ) @@ -36,7 +40,24 @@ if not exist "%APP_DIR%index.html" ( exit /b 1 ) +call :prepare_host_toolchain +if errorlevel 1 goto :host_compiler_missing + +rem A previous configure may have captured ESP-IDF's cross clang. CMake caches +rem compiler identity, so discard only the small host-tool configure cache when +rem it points at an Espressif compiler. This never touches embedded sdkconfig. +if exist "%BUILD_DIR%\CMakeCache.txt" ( + findstr /I /C:"Espressif/tools/esp-clang" /C:"Espressif\tools\esp-clang" "%BUILD_DIR%\CMakeCache.txt" >nul 2>nul + if not errorlevel 1 ( + echo Removing stale ESP-IDF cross-compiler cache... + del /q "%BUILD_DIR%\CMakeCache.txt" >nul 2>nul + if exist "%BUILD_DIR%\CMakeFiles" rmdir /s /q "%BUILD_DIR%\CMakeFiles" + ) +) + echo ARStack61850 SMV Injector GUI +echo Repository: %REPO_ROOT% +echo Native host compiler: %HOST_COMPILER_LABEL% echo Building smart SCL profile engine... cmake -S "%APP_DIR%host" -B "%BUILD_DIR%" -G Ninja -DCMAKE_BUILD_TYPE=Release if errorlevel 1 goto :build_failed @@ -49,6 +70,11 @@ if not exist "%PROFILE_TOOL%" ( exit /b 1 ) +if "%BUILD_ONLY%"=="1" ( + echo Smart SCL profile engine build PASS. + exit /b 0 +) + echo Local UI: http://127.0.0.1:%PORT%/ echo Smart SCL engine: %PROFILE_TOOL% echo. @@ -60,8 +86,75 @@ start "" "http://127.0.0.1:%PORT%/" python "%APP_DIR%host_server.py" --profile-tool "%PROFILE_TOOL%" --port %PORT% exit /b %ERRORLEVEL% +:prepare_host_toolchain +rem ESP-IDF PowerShell intentionally exports an embedded clang toolchain. That +rem compiler cannot link a native Windows host executable. Clear compiler hints +rem first, then deliberately select a native Windows compiler. +set "CC=" +set "CXX=" +set "AR=" +set "AS=" +set "LD=" +set "CFLAGS=" +set "CXXFLAGS=" +set "LDFLAGS=" +set "HOST_COMPILER_LABEL=" + +where cl.exe >nul 2>nul +if not errorlevel 1 ( + set "CC=cl.exe" + set "CXX=cl.exe" + set "HOST_COMPILER_LABEL=MSVC cl.exe" + exit /b 0 +) + +set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" +if exist "%VSWHERE%" ( + set "VSINSTALL=" + for /f "usebackq tokens=*" %%I in (`"%VSWHERE%" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VSINSTALL=%%I" + if defined VSINSTALL ( + if exist "%VSINSTALL%\Common7\Tools\VsDevCmd.bat" ( + call "%VSINSTALL%\Common7\Tools\VsDevCmd.bat" -no_logo -arch=x64 -host_arch=x64 >nul 2>nul + where cl.exe >nul 2>nul + if not errorlevel 1 ( + set "CC=cl.exe" + set "CXX=cl.exe" + set "HOST_COMPILER_LABEL=MSVC x64 via Visual Studio Build Tools" + exit /b 0 + ) + ) + ) +) + +where g++.exe >nul 2>nul +if not errorlevel 1 ( + where gcc.exe >nul 2>nul + if not errorlevel 1 ( + set "CC=gcc.exe" + set "CXX=g++.exe" + set "HOST_COMPILER_LABEL=MinGW GCC/G++" + exit /b 0 + ) +) + +exit /b 1 + +:host_compiler_missing +echo. +echo ERROR: no native Windows C++ compiler was found. +echo The ESP-IDF esp-clang compiler is an embedded cross compiler and cannot + echo link the Windows host-side SCL profile tool. +echo. +echo Install Microsoft Visual Studio Build Tools with the Desktop development + echo with C++ workload, then run this same command again. The launcher will + echo discover it automatically; do not change the ESP32-P4 toolchain. +echo. +pause +exit /b 1 + :build_failed echo. echo ERROR: smart SCL profile engine build failed. +echo Native compiler selected: %HOST_COMPILER_LABEL% pause exit /b 1 From 58a37db9e0d5e17b8191c0b31f0e72bd45c63fdb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 13:47:47 +0700 Subject: [PATCH 28/36] ci(gui): exercise Windows host-tool launcher --- .github/workflows/smv-injector-gui.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/smv-injector-gui.yml b/.github/workflows/smv-injector-gui.yml index 86b9a5215..15b7c9419 100644 --- a/.github/workflows/smv-injector-gui.yml +++ b/.github/workflows/smv-injector-gui.yml @@ -88,3 +88,23 @@ jobs: --data-binary @tests/fixtures/scl/sv-4800-structured-4i4v.scd \ 'http://127.0.0.1:8765/api/scl/inspect?counterModulus=4800' >/tmp/api.json grep -q '"deviceSupport":"ready"' /tmp/api.json + + windows-launcher-smoke: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Verify native-host launcher + shell: cmd + run: apps\smv_injector_gui\run.cmd --build-only + + - name: Verify Windows profile compiler + shell: powershell + 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 + if (-not (Select-String -Path profile-a.json -SimpleMatch '"deviceSupport":"ready"')) { + throw 'Windows profile compiler did not produce a deployable Class-A fixture result' + } From 5a58cd83b66758acccb5fc6d147bf2ec1f3fe6a3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 13:59:57 +0700 Subject: [PATCH 29/36] fix(gui): discover installed Visual Studio generically --- apps/smv_injector_gui/run.cmd | 86 +++++++++++++++++++++++++++++++---- 1 file changed, 78 insertions(+), 8 deletions(-) diff --git a/apps/smv_injector_gui/run.cmd b/apps/smv_injector_gui/run.cmd index fdf85eec4..458267cac 100644 --- a/apps/smv_injector_gui/run.cmd +++ b/apps/smv_injector_gui/run.cmd @@ -58,6 +58,7 @@ if exist "%BUILD_DIR%\CMakeCache.txt" ( echo ARStack61850 SMV Injector GUI echo Repository: %REPO_ROOT% echo Native host compiler: %HOST_COMPILER_LABEL% +if defined VSINSTALL_USED echo Visual Studio: %VSINSTALL_USED% echo Building smart SCL profile engine... cmake -S "%APP_DIR%host" -B "%BUILD_DIR%" -G Ninja -DCMAKE_BUILD_TYPE=Release if errorlevel 1 goto :build_failed @@ -89,7 +90,7 @@ exit /b %ERRORLEVEL% :prepare_host_toolchain rem ESP-IDF PowerShell intentionally exports an embedded clang toolchain. That rem compiler cannot link a native Windows host executable. Clear compiler hints -rem first, then deliberately select a native Windows compiler. +rem first, then deliberately select any installed native Windows compiler. set "CC=" set "CXX=" set "AR=" @@ -99,31 +100,88 @@ set "CFLAGS=" set "CXXFLAGS=" set "LDFLAGS=" set "HOST_COMPILER_LABEL=" +set "VSINSTALL_USED=" +set "VISUAL_STUDIO_FOUND=" +set "VISUAL_STUDIO_PATH=" +rem If the current shell already has native MSVC configured, use it directly. where cl.exe >nul 2>nul if not errorlevel 1 ( set "CC=cl.exe" set "CXX=cl.exe" - set "HOST_COMPILER_LABEL=MSVC cl.exe" + set "HOST_COMPILER_LABEL=MSVC cl.exe from current environment" exit /b 0 ) +rem Discover Visual Studio by capability, not by product year. Include preview / +rem prerelease installations so newer Visual Studio releases work without any +rem launcher change. vswhere is installed by the Visual Studio Installer. set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" if exist "%VSWHERE%" ( set "VSINSTALL=" - for /f "usebackq tokens=*" %%I in (`"%VSWHERE%" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VSINSTALL=%%I" + for /f "usebackq tokens=*" %%I in (`"%VSWHERE%" -latest -prerelease -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VSINSTALL=%%I" if defined VSINSTALL ( + set "VISUAL_STUDIO_FOUND=1" + set "VISUAL_STUDIO_PATH=%VSINSTALL%" if exist "%VSINSTALL%\Common7\Tools\VsDevCmd.bat" ( call "%VSINSTALL%\Common7\Tools\VsDevCmd.bat" -no_logo -arch=x64 -host_arch=x64 >nul 2>nul where cl.exe >nul 2>nul if not errorlevel 1 ( set "CC=cl.exe" set "CXX=cl.exe" - set "HOST_COMPILER_LABEL=MSVC x64 via Visual Studio Build Tools" + set "HOST_COMPILER_LABEL=MSVC x64 via installed Visual Studio" + set "VSINSTALL_USED=%VSINSTALL%" exit /b 0 ) ) ) + + rem If Visual Studio exists but the VC workload query did not match, remember + rem the installation so the error can ask to modify that existing install + rem instead of recommending another Visual Studio version. + if not defined VISUAL_STUDIO_FOUND ( + set "VSINSTALL=" + for /f "usebackq tokens=*" %%I in (`"%VSWHERE%" -latest -prerelease -products * -property installationPath`) do set "VSINSTALL=%%I" + if defined VSINSTALL ( + set "VISUAL_STUDIO_FOUND=1" + set "VISUAL_STUDIO_PATH=%VSINSTALL%" + if exist "%VSINSTALL%\Common7\Tools\VsDevCmd.bat" ( + call "%VSINSTALL%\Common7\Tools\VsDevCmd.bat" -no_logo -arch=x64 -host_arch=x64 >nul 2>nul + where cl.exe >nul 2>nul + if not errorlevel 1 ( + set "CC=cl.exe" + set "CXX=cl.exe" + set "HOST_COMPILER_LABEL=MSVC x64 via installed Visual Studio" + set "VSINSTALL_USED=%VSINSTALL%" + exit /b 0 + ) + ) + ) + ) +) + +rem Final Visual Studio fallback if vswhere is unavailable: scan version-neutral +rem install roots, including future product-year folders. +for %%R in ("%ProgramFiles%\Microsoft Visual Studio" "%ProgramFiles(x86)%\Microsoft Visual Studio") do ( + if exist "%%~R" ( + for /d %%V in ("%%~R\*") do ( + for /d %%E in ("%%~fV\*") do ( + if exist "%%~fE\Common7\Tools\VsDevCmd.bat" ( + set "VISUAL_STUDIO_FOUND=1" + set "VISUAL_STUDIO_PATH=%%~fE" + call "%%~fE\Common7\Tools\VsDevCmd.bat" -no_logo -arch=x64 -host_arch=x64 >nul 2>nul + where cl.exe >nul 2>nul + if not errorlevel 1 ( + set "CC=cl.exe" + set "CXX=cl.exe" + set "HOST_COMPILER_LABEL=MSVC x64 via installed Visual Studio" + set "VSINSTALL_USED=%%~fE" + exit /b 0 + ) + ) + ) + ) + ) ) where g++.exe >nul 2>nul @@ -141,13 +199,24 @@ exit /b 1 :host_compiler_missing echo. -echo ERROR: no native Windows C++ compiler was found. +echo ERROR: no usable native Windows C++ compiler was found. echo The ESP-IDF esp-clang compiler is an embedded cross compiler and cannot echo link the Windows host-side SCL profile tool. echo. -echo Install Microsoft Visual Studio Build Tools with the Desktop development - echo with C++ workload, then run this same command again. The launcher will - echo discover it automatically; do not change the ESP32-P4 toolchain. +if defined VISUAL_STUDIO_FOUND ( + echo Visual Studio was detected at: + echo %VISUAL_STUDIO_PATH% + echo. + echo Keep that existing Visual Studio installation. Open Visual Studio Installer, + echo choose Modify for that installation, and enable Desktop development with C++ + echo ^(MSVC x64/x86 build tools plus a Windows SDK^). No specific Visual Studio + echo product year is required by ARStack61850. +) else ( + echo No Visual Studio installation with native C++ tools was detected. + echo Install or modify any supported Visual Studio edition with Desktop development + echo with C++, or provide a native MinGW GCC/G++ toolchain on PATH. ARStack61850 + echo does not require a specific Visual Studio product year. +) echo. pause exit /b 1 @@ -156,5 +225,6 @@ exit /b 1 echo. echo ERROR: smart SCL profile engine build failed. echo Native compiler selected: %HOST_COMPILER_LABEL% +if defined VSINSTALL_USED echo Visual Studio: %VSINSTALL_USED% pause exit /b 1 From 5780dafe3fb23b37cfa02f7aed57ff6c22a24b5e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 14:14:46 +0700 Subject: [PATCH 30/36] fix(gui): self-heal missing host build scaffold --- apps/smv_injector_gui/run.cmd | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/apps/smv_injector_gui/run.cmd b/apps/smv_injector_gui/run.cmd index 458267cac..52c4d5354 100644 --- a/apps/smv_injector_gui/run.cmd +++ b/apps/smv_injector_gui/run.cmd @@ -40,6 +40,36 @@ if not exist "%APP_DIR%index.html" ( exit /b 1 ) +if not exist "%APP_DIR%host_server.py" ( + echo ERROR: GUI host_server.py is missing from the local working tree. + echo Restore the GUI files from the current branch, then run this launcher again: + echo git -C "%REPO_ROOT%" restore --source=HEAD -- apps/smv_injector_gui + pause + exit /b 1 +) + +rem The tiny host CMake scaffold is tracked in Git, but a locally deleted tracked +rem directory can survive a fast-forward pull. Self-heal this generated-style +rem scaffold so the operator never has to restore one internal build file by hand. +if not exist "%APP_DIR%host\CMakeLists.txt" ( + echo Repairing missing local host build scaffold... + if not exist "%APP_DIR%host" mkdir "%APP_DIR%host" >nul 2>nul + > "%APP_DIR%host\CMakeLists.txt" echo cmake_minimum_required^(VERSION 3.20^) + >> "%APP_DIR%host\CMakeLists.txt" echo project^(ARStackSmvGuiHost LANGUAGES CXX^) + >> "%APP_DIR%host\CMakeLists.txt" echo. + >> "%APP_DIR%host\CMakeLists.txt" echo set^(ARIEC61850_BUILD_TESTS OFF CACHE BOOL "" FORCE^) + >> "%APP_DIR%host\CMakeLists.txt" echo set^(ARIEC61850_BUILD_TOOLS OFF CACHE BOOL "" FORCE^) + >> "%APP_DIR%host\CMakeLists.txt" echo set^(ARIEC61850_BUILD_FUZZERS OFF CACHE BOOL "" FORCE^) + >> "%APP_DIR%host\CMakeLists.txt" echo set^(ARIEC61850_ENABLE_SANITIZERS OFF CACHE BOOL "" FORCE^) + >> "%APP_DIR%host\CMakeLists.txt" echo. + >> "%APP_DIR%host\CMakeLists.txt" echo add_subdirectory^(${CMAKE_CURRENT_LIST_DIR}/../../.. arstack-core^) + >> "%APP_DIR%host\CMakeLists.txt" echo. + >> "%APP_DIR%host\CMakeLists.txt" echo add_executable^(ariec61850_smv_profile_inspect + >> "%APP_DIR%host\CMakeLists.txt" echo ${CMAKE_CURRENT_LIST_DIR}/../../../tools/smv_profile_inspect.cpp^) + >> "%APP_DIR%host\CMakeLists.txt" echo target_link_libraries^(ariec61850_smv_profile_inspect PRIVATE ARIEC61850::core^) + >> "%APP_DIR%host\CMakeLists.txt" echo target_compile_features^(ariec61850_smv_profile_inspect PRIVATE cxx_std_20^) +) + call :prepare_host_toolchain if errorlevel 1 goto :host_compiler_missing From 9bd7451df1852cd0e25ad51f580927bf111b6c04 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 14:15:21 +0700 Subject: [PATCH 31/36] test(gui): cover missing host scaffold recovery --- .github/workflows/smv-injector-gui.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/smv-injector-gui.yml b/.github/workflows/smv-injector-gui.yml index 15b7c9419..1a96d3934 100644 --- a/.github/workflows/smv-injector-gui.yml +++ b/.github/workflows/smv-injector-gui.yml @@ -95,7 +95,11 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Verify native-host launcher + - name: Simulate locally deleted host scaffold + shell: powershell + run: Remove-Item 'apps\smv_injector_gui\host' -Recurse -Force + + - name: Verify native-host launcher and self-heal shell: cmd run: apps\smv_injector_gui\run.cmd --build-only From bcbd74997fe79dc087e7353382fd8786f0dee220 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 14:21:12 +0700 Subject: [PATCH 32/36] fix(gui): self-heal missing tracked runtime assets --- apps/smv_injector_gui/host_server.py | 50 ++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/apps/smv_injector_gui/host_server.py b/apps/smv_injector_gui/host_server.py index 656f18059..e047e077d 100644 --- a/apps/smv_injector_gui/host_server.py +++ b/apps/smv_injector_gui/host_server.py @@ -13,7 +13,55 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer APP_DIR = pathlib.Path(__file__).resolve().parent +REPO_ROOT = APP_DIR.parent.parent MAX_SCL_BYTES = 16 * 1024 * 1024 +REQUIRED_RUNTIME_ASSETS = ( + "index.html", + "styles.css", + "responsive.css", + "app.js", + "scl_bridge_bootstrap.js", + "profile_bridge.js", + "profile_bridge.css", +) + + +def ensure_runtime_assets() -> None: + """Restore only missing tracked GUI assets from the current Git HEAD. + + A locally deleted tracked file can survive a fast-forward pull. The operator + should not have to discover that from a silent browser 404. Existing files + are never overwritten, so intentional local edits remain untouched. + """ + + missing = [name for name in REQUIRED_RUNTIME_ASSETS if not (APP_DIR / name).is_file()] + if not missing: + return + + git = "git.exe" if os.name == "nt" else "git" + paths = [f"apps/smv_injector_gui/{name}" for name in missing] + print(f"Repairing missing tracked GUI assets: {', '.join(missing)}") + try: + completed = subprocess.run( + [git, "-C", str(REPO_ROOT), "restore", "--source=HEAD", "--", *paths], + capture_output=True, + text=True, + timeout=15, + check=False, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, + ) + except (OSError, subprocess.TimeoutExpired) as error: + raise SystemExit(f"Unable to restore missing GUI assets: {error}") from error + + if completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() or "git restore failed" + raise SystemExit(f"Unable to restore missing GUI assets: {detail}") + + still_missing = [name for name in missing if not (APP_DIR / name).is_file()] + if still_missing: + raise SystemExit( + "Required GUI assets are still missing after restore: " + ", ".join(still_missing) + ) class Handler(SimpleHTTPRequestHandler): @@ -111,6 +159,8 @@ def main() -> int: parser.add_argument("--port", type=int, default=8765) args = parser.parse_args() + ensure_runtime_assets() + tool = pathlib.Path(args.profile_tool).resolve() if not tool.is_file(): raise SystemExit(f"Profile compiler not found: {tool}") From ffc79495591fa7bf76e7d33c367b8dded5a09104 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 14:48:46 +0700 Subject: [PATCH 33/36] docs: record SMV injector completion boundary and bench evidence --- docs/SMV_INJECTOR_STATUS.md | 146 ++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 docs/SMV_INJECTOR_STATUS.md diff --git a/docs/SMV_INJECTOR_STATUS.md b/docs/SMV_INJECTOR_STATUS.md new file mode 100644 index 000000000..eeedfbc78 --- /dev/null +++ b/docs/SMV_INJECTOR_STATUS.md @@ -0,0 +1,146 @@ +# SMV Injector status and completion boundary + +This document records the current engineering status of the ESP32-P4 Sampled Values injector and defines what is considered complete versus what remains as validation, timing, analyzer and product-hardening work. + +## Status summary + +**SMV Injector functional phase: COMPLETE for the current supported runtime profile.** + +The completed functional path is: + +```text +Import SCL / CID / SCD / IID + -> C++ IEC 61850 SCL parser + -> normalized SvPublisherProfile + -> stream selection and Class A/B/C diagnostics + -> explicit sample-counter-policy validation when required + -> deploy immutable profile while STOPPED + -> ESP32-P4 arms the runtime profile + -> START + -> deterministic Sampled Values publication + -> live I/U/phase/frequency/quality updates while RUNNING + -> STOP +``` + +The current completion statement is intentionally narrower than a standards-conformance or production-readiness claim. + +## Physical bench evidence achieved + +The current branch has been exercised on an ESP32-P4 Ethernet board with a real engineering-file-derived 4800 fps profile. + +Observed results include: + +- engineering file compiled by the C++ SCL engine; +- one SV stream resolved and promoted to Class A after explicit counter-policy confirmation; +- immutable runtime profile deployed to the ESP32-P4; +- SCL-derived `svID`, APPID, VLAN/PCP intent, configuration revision, publisher rate and counter modulus displayed as the armed profile; +- runtime transitioned to 4800 fps; +- device telemetry reported approximately 4800 samples/s; +- device telemetry reported zero missed slots in the retained run; +- device telemetry reported zero canonical TX failures in the retained run; +- live 50 Hz three-phase waveform remained observable through the diagnostic capture path; +- diagnostic stream inherited the active profile rate and configuration revision, confirming that it was produced by the same active runtime profile; +- live engineering-value changes remain independent from immutable stream identity/layout. + +This is sufficient to call the **injector workflow itself functionally complete for the supported 4I+4V profile boundary**. + +## Current supported device-profile boundary + +The first deployable embedded profile is deliberately bounded: + +- one ASDU; +- `SmpPerSec` with an absolute publisher rate; +- explicit validated `smpCnt` modulus; +- eight logical channels ordered as `Ia, Ib, Ic, In, Ua, Ub, Uc, Un`; +- 16 ordered leaves as eight `INT32 value + Quality` pairs; +- 64-byte sample payload; +- configurable destination MAC, APPID, VLAN presence/VID/PCP, `svID`, `confRev` and supported ASDU field-presence options; +- rational GPTimer scheduling for rates that are not an integer number of microseconds, including 4800 fps; +- truthful `smpSynch=0` until measured synchronization exists. + +Structurally valid profiles outside this boundary may still be parsed and explained by the host compiler, but deployment must remain blocked rather than guessing an embedded payload layout. + +## Canonical stream versus diagnostic mirror + +The firmware currently transmits two distinct streams during bench validation: + +```text +canonical stream + SCL-derived multicast destination + SCL-derived APPID + SCL-derived svID + configured VLAN/PCP + standards-facing identity + + diagnostic mirror + broadcast destination + APPID 0x4F01 + svID AR_DIAG_SV1 + untagged + capture/debug identity only +``` + +The diagnostic mirror exists because the current host USB-Ethernet capture path does not reliably expose the canonical multicast/priority-tagged stream. The diagnostic identity is intentionally different so a tool cannot confuse the mirror with the configured process-bus stream. + +Seeing `AR_DIAG_SV1` in the current analyzer therefore does **not** mean the SCL profile failed to deploy. It means the analyzer is observing the lab mirror while the canonical SCL stream is transmitted in parallel. + +## What is not yet claimed + +The following remain outside the functional-complete statement: + +- trusted on-wire proof of canonical multicast destination, APPID, `svID` and VLAN/PCP on an independent raw capture path; +- formal IEC 61850-9-2, IEC 61869-9 or legacy profile conformance; +- hardware-TX timestamp statistics; +- IEC/IEEE 61850-9-3 PTP discipline and synchronized `smpSynch` behavior; +- universal engineering scaling inferred from arbitrary SCL files; +- arbitrary DataSet layouts and arbitrary basic-type combinations on the embedded target; +- multi-stream publication; +- fault/scenario scripting and COMTRADE playback; +- production packaging, signed desktop distribution and long-duration qualification; +- independent subscriber/relay interoperability matrix. + +These are follow-on engineering phases, not evidence that the current injector workflow is unfinished. + +## Next hardware phase: independent analyzer node + +A second ESP32-P4 Ethernet board is a suitable next validation node. + +The intended analyzer architecture is: + +```text +process-bus Ethernet + | + v +ESP32-P4 raw EMAC RX + | + +-- promiscuous / all-multicast receive + +-- preserve and parse IEEE 802.1Q tags + +-- classify EtherType 0x88BA + +-- decode SV APPID / ASDU / svID / smpCnt / confRev / quality + +-- calculate continuity and timing evidence + | + v +Observed-wire profile + | + v +GUI configured-vs-observed comparison +``` + +The analyzer can only inspect frames that physically reach its Ethernet port. In a switched process-bus lab, use a trusted TAP or managed-switch mirror/SPAN port that preserves VLAN tags. + +The goal is to remove the current Windows USB-NIC capture ambiguity and make configured-vs-observed evidence a first-class ARStack61850 capability. + +## Product interpretation + +The injector has crossed the boundary from a fixed bench waveform generator to an **SCL-driven IEC 61850 Sampled Values injection instrument** for the current supported profile. + +The next work should focus on verification depth and profile breadth rather than re-opening the already proven core workflow: + +```text +Configured engineering intent + -> deterministic injector + -> independent raw analyzer + -> configured-vs-observed evidence +``` + +No decoded packet or successful bench run by itself is treated as a formal conformance certificate. \ No newline at end of file From 8ea7ec6dbc79e85f1ec142bf5a4c81239cd5b73c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 14:49:16 +0700 Subject: [PATCH 34/36] docs: update GUI README with completed SCL-driven injector workflow --- apps/smv_injector_gui/README.md | 58 ++++++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/apps/smv_injector_gui/README.md b/apps/smv_injector_gui/README.md index eec90e23f..ee5a42dd1 100644 --- a/apps/smv_injector_gui/README.md +++ b/apps/smv_injector_gui/README.md @@ -2,7 +2,9 @@ Operator-facing control plane for the ESP32-P4 Sampled Values injector. -The GUI is not a waveform generator. The PC compiles engineering intent and sends bounded control/profile updates; the ESP32-P4 remains the realtime publisher. +The GUI is not the realtime waveform generator. The PC compiles engineering intent and sends bounded profile/control updates; the ESP32-P4 remains the realtime publisher. + +For the current completion boundary and retained bench evidence, see [`../../docs/SMV_INJECTOR_STATUS.md`](../../docs/SMV_INJECTOR_STATUS.md). ## Current workflow @@ -11,15 +13,25 @@ Import SCL / CID / SCD / IID -> C++ SCL parser -> normalized SvPublisherProfile -> select SV stream - -> resolve warnings / counter policy - -> configure test scaling when SCL does not establish it + -> resolve Class A/B/C diagnostics + -> confirm sample-counter policy when required + -> configure explicit test scaling when SCL does not establish it -> Deploy to device while STOPPED + -> immutable profile armed on ESP32-P4 -> Start -> live I/U/phase/frequency/quality changes -> Stop ``` -The browser does not implement a second IEC 61850 semantic parser. `run.cmd` builds the focused C++ profile compiler and starts a localhost-only Python bridge. Uploaded engineering data is written to a temporary local file only for compilation and removed immediately after the request. +The browser does not implement a second IEC 61850 semantic parser. `run.cmd` builds the focused native C++ profile compiler and starts a localhost-only Python bridge. Engineering data is written to a temporary local file only for compilation and removed immediately after the request. + +## Functional status + +The SCL-driven injector workflow is physically proven for the current supported 4I+4V device profile. + +A real engineering-file-derived 4800 fps profile has been compiled, validated, deployed and run on the ESP32-P4 with retained device telemetry showing approximately 4800 samples/s, zero missed slots and zero canonical TX failures in the observed run. + +This is a functional-completion statement, not a claim of universal SCL support or formal IEC conformance. ## Supported device profile in this phase @@ -30,7 +42,8 @@ The host parser can inspect broader SCL SV structures, but the first ESP32-P4 de - explicit/validated sample-counter modulus; - 16 ordered leaves as 8 `INT32 value + Quality` pairs; - 64-byte sample payload; -- configurable destination MAC, APPID, VLAN/PCP, svID, confRev and supported ASDU field presence. +- channel order `Ia, Ib, Ic, In, Ua, Ub, Uc, Un`; +- configurable destination MAC, APPID, VLAN/PCP, `svID`, `confRev` and supported ASDU field presence. A structurally valid but unsupported layout remains visible in the GUI and is blocked from deployment. The firmware does not guess a payload layout. @@ -42,6 +55,22 @@ SCL sampling rate does not universally prove the `smpCnt` wrap policy. When the Generic SCL does not always establish physical scaling. The GUI therefore exposes current and voltage counts-per-unit conversion for the active test profile instead of claiming a universal scale. This scaling affects only engineering-value conversion in the control plane; it does not change realtime scheduling. +## Canonical stream and diagnostic visibility + +The active SCL profile is the canonical standards-facing stream identity. During the current bench phase, firmware also emits a separate untagged broadcast diagnostic mirror: + +```text +canonical + -> SCL-derived multicast destination / APPID / svID / VLAN + +diagnostic mirror + -> broadcast / APPID 0x4F01 / svID AR_DIAG_SV1 / untagged +``` + +The mirror exists because the currently tested host USB-Ethernet capture path does not reliably expose multicast/priority-tagged canonical traffic. If a raw analyzer shows `AR_DIAG_SV1`, it is seeing the diagnostic mirror, not evidence that the SCL profile failed to deploy. + +The diagnostic mirror follows the active publisher rate and configuration revision but intentionally keeps a different identity. + ## Run 1. Flash firmware from the same `feature/smv-scl-profile-bridge` branch. @@ -52,11 +81,18 @@ Generic SCL does not always establish physical scaling. The GUI therefore expose .\apps\smv_injector_gui\run.cmd ``` -The launcher incrementally builds the smart SCL profile tool into `build-smv-gui`, starts the local control-plane server on `127.0.0.1:8765`, and opens the GUI. +The launcher: + +- detects a native Windows C++ compiler by capability rather than Visual Studio product year; +- incrementally builds the smart SCL profile tool into `build-smv-gui`; +- starts the local control-plane server on `127.0.0.1:8765`; +- opens the GUI. 4. Click **Connect device** and select the ESP32-P4 serial port. -5. Import an engineering file, select an SV stream, resolve any Class-B counter-policy requirement, then **Deploy to device** while STOPPED. -6. Press **Start**. Magnitude, phase, frequency, quality and enabled state remain live controls while running. +5. Import an engineering file and select an SV stream. +6. Resolve any Class-B counter-policy requirement. +7. **Deploy to device** while STOPPED. +8. Press **Start**. Magnitude, phase, frequency, quality and enabled state remain live controls while running. ## Realtime boundary @@ -86,4 +122,8 @@ C++ profile compiler ---- GUI engineering values Profile identity/layout changes require `STOP -> validate -> deploy -> arm`. Live signal values do not rebuild BER and do not restart `smpCnt`. -The current rational GPTimer scheduler supports runtime publisher rates without rounding a fractional-microsecond period into a permanently wrong fixed interval; for example, 4800 events/s uses a repeating 208/208/209 microsecond schedule at the 1 MHz timer domain. +The rational GPTimer scheduler supports runtime publisher rates without permanently rounding fractional-microsecond periods. For example, 4800 events/s uses a repeating 208/208/209 microsecond schedule in the 1 MHz timer domain. + +## Next validation surface + +The preferred next hardware validation path is an independent raw Ethernet analyzer, potentially using a second ESP32-P4 board in promiscuous/all-multicast receive mode with application-layer VLAN parsing. That analyzer should compare configured SCL truth against observed canonical wire truth without relying on the current Windows USB-NIC capture path. \ No newline at end of file From 97751846fa910fa755bba2b6e5bc8b539c0d0890 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 14:50:04 +0700 Subject: [PATCH 35/36] docs: promote ESP32-P4 injector README to SCL-driven runtime status --- embedded/esp32p4_smv_injector/README.md | 261 +++++++++++++++--------- 1 file changed, 164 insertions(+), 97 deletions(-) diff --git a/embedded/esp32p4_smv_injector/README.md b/embedded/esp32p4_smv_injector/README.md index 7979e12c3..da4bbc9b8 100644 --- a/embedded/esp32p4_smv_injector/README.md +++ b/embedded/esp32p4_smv_injector/README.md @@ -1,68 +1,139 @@ -# ESP32-P4-ETH SMV Injector — P0/P1 + P2 50 Hz bring-up +# ESP32-P4 Ethernet Sampled Values Injector -This target brings up the current ESP32-P4 Ethernet development board using the internal EMAC + onboard RMII PHY and publishes IEC 61850 Sampled Values through the existing ARStack process-bus codec. +This target runs the ARStack61850 Sampled Values injector on the current ESP32-P4 Ethernet development board using the internal EMAC and onboard RMII PHY. -See [`ARCHITECTURE.md`](ARCHITECTURE.md) for the standards-first SCL/profile/PTP direction after the current P2 bring-up. +The firmware has progressed beyond the original fixed 4000 fps bring-up. The current runtime accepts a bounded immutable SCL-compiled publisher profile from the host GUI, arms that profile while STOPPED, and publishes deterministic Sampled Values while live signal values remain mutable. + +See: + +- [`ARCHITECTURE.md`](ARCHITECTURE.md) for the standards-first system boundaries; +- [`../../docs/SMV_INJECTOR_STATUS.md`](../../docs/SMV_INJECTOR_STATUS.md) for the current completion statement and physical bench evidence. ## Current hardware result -P0/P1 and the first P2 realtime path have been proven on real hardware: +The current ESP32-P4 path has been proven on real hardware: + +- ESP32-P4 rev 1.3 boots and drives the onboard RMII PHY; +- raw Layer-2 TX reaches an independent host capture path; +- IEC 61850 Sampled Values are decoded from the generated `0x88BA` traffic; +- the development 4000 fps profile has sustained stable device telemetry with zero TX failures and zero missed timer notifications in retained runs; +- the SCL-driven runtime has physically transitioned to a real engineering-file-derived 4800 fps profile; +- the 4800 fps retained run reported approximately 4800 samples/s, zero canonical TX failures and zero missed slots; +- the active profile exposed SCL-derived `svID`, APPID, VLAN/PCP intent, configuration revision, publisher rate and counter modulus in the GUI; +- three-phase current/voltage waveforms remained observable through the diagnostic capture path; +- profile identity/layout is immutable while RUNNING, while magnitude, phase, frequency, quality and channel enable state remain live controls. + +This proves the injector workflow for the supported profile boundary. It does not by itself prove formal IEC conformance or trusted on-wire visibility of every canonical multicast/VLAN field. + +## Runtime profile model + +The embedded runtime currently accepts a bounded `RuntimePublisherProfile` containing: + +- destination MAC; +- APPID; +- VLAN present/VID/PCP; +- configuration revision; +- publisher rate; +- sample-counter modulus; +- `nofASDU`; +- supported ASDU field-presence flags; +- `svID`; +- DataSet reference when present; +- profile generation. + +The supported embedded DataSet layout is currently: + +```text +Ia, Ib, Ic, In, Ua, Ub, Uc, Un +``` + +with eight `INT32 sample + UINT32 quality` pairs for a 64-byte sample payload. + +Profiles that cannot be mapped safely to this bounded layout must be rejected rather than guessed. + +## Runtime state rule + +```text +STOPPED + | + +-- receive / validate immutable publisher profile + | + +-- COMMIT / ARM + | + v +RUNNING + | + +-- magnitude may change live + +-- phase may change live + +-- frequency may change live + +-- quality may change live + +-- channel enable may change live + | + +-- MAC / APPID / VLAN / svID / DataSet / rate / layout remain immutable + | + v +STOPPED +``` + +A structural profile change therefore requires `STOP -> validate -> deploy -> arm`. + +## Rational sample scheduler -- ESP32-P4 rev 1.3 boots and drives the onboard RMII PHY. -- Raw Layer-2 transmit reaches a host capture NIC. -- Independent packet capture recognizes the generated `0x88BA` payload as IEC 61850 Sampled Values. -- The GPTimer-driven publisher sustains about 4000 samples/s on the current bench with device-side TX failures and missed timer notifications at zero in the observed run. -- A raw process-bus analyzer reconstructs balanced 50 Hz three-phase voltage/current waveforms from the diagnostic stream and reports continuous sequence integrity. -- The tested USB-Ethernet host capture path reports roughly the correct average rate but shows large individual arrival-time excursions. Treat those timestamps as capture-path evidence, not device-TX timing evidence. -- The tested host capture path does not reliably expose the normal IEC 61850 multicast/priority-tagged stream. The firmware therefore keeps a lab-only untagged broadcast diagnostic stream so waveform/timing validation can continue without pretending that stream is the standards-facing output. +The publisher uses a 1 MHz GPTimer and a rational one-shot scheduling cursor instead of permanently rounding fractional-microsecond periods. -## P2 50 Hz publisher +Examples: -The default firmware enables the first realtime publisher foundation: +```text +4000 fps -> 250 us, 250 us, 250 us, ... + +4800 fps -> 208 us, 208 us, 209 us, ... +``` + +This preserves the requested long-term event rate in the timer domain. + +The realtime TX path prebuilds the SV frame template. Per sample it patches only bounded fixed-width fields such as `smpCnt`, sample values and quality; it does not perform XML parsing or rebuild the full BER structure in the hot loop. -- 50 Hz nominal frequency. -- 80 samples/cycle. -- 4000 frames/s. -- 250 us GPTimer period. -- canonical `smpCnt` wraps at 4000. -- prebuilt fixed-size frame templates; no BER encode or heap allocation in the hot TX loop. -- fixed 2-byte `smpCnt` and 4-byte `confRev` patch fields in the runtime template. -- balanced three-phase sine generator. -- dataset order `Ia, Ib, Ic, In, Ua, Ub, Uc, Un`. -- each channel is `(INT32 sample, UINT32 quality)`. -- current payload scaling: 1 mA/count. -- voltage payload scaling: 10 mV/count. -- default phase current: 1.000 A RMS. -- default phase-to-neutral voltage: approximately 57.74 V RMS (100 V line-to-line). -- `smpSynch=0` remains truthful until a measured PTP/1PPS lock exists. +## Canonical stream and diagnostic mirror -### Standards-facing canonical stream +During bench validation the firmware transmits two distinct streams. -The current canonical stream is aligned with `01_SV_Stream_4I+4V_(9-2LE).scd` stream #1: +### Canonical stream -- destination `01:0C:CD:04:00:01`; -- APPID `0x4000`; -- `svID=MU01_SV1`; -- `confRev=1`; -- VLAN PCP 4 / VID 0 enabled by default; -- one ASDU; -- dataset-reference field omitted because the supplied SCL has `dataSet=false`; -- explicit `smpRate` / `smpMod` fields omitted because the supplied SCL has `sampleRate=false`. +The canonical stream uses the active runtime profile: -This is the interoperability target. The build still does **not** claim final strict 9-2LE/IEC 61869-9 conformance; exact profile rules, synchronization, canonical multicast/VLAN capture on a trusted path and relay interoperability remain acceptance gates. +- configured multicast destination MAC; +- configured APPID; +- configured VLAN/PCP when enabled; +- configured `svID`; +- configured `confRev`; +- configured publisher rate; +- configured `smpCnt` modulus; +- supported configured ASDU field-presence policy. -### Lab diagnostic stream +This is the standards-facing stream identity. -For the current host-capture limitation the firmware also publishes a separate diagnostic stream: +### Diagnostic mirror -- destination `FF:FF:FF:FF:FF:FF`; +The separate lab mirror currently uses: + +- broadcast destination `FF:FF:FF:FF:FF:FF`; - APPID `0x4F01`; - `svID=AR_DIAG_SV1`; -- untagged; -- explicit `smpRate=4000` and `smpMod=SmpPerSec` so a raw analyzer can reconstruct the waveform without SCL binding; -- independent normal 16-bit sample counter. +- untagged Ethernet; +- the same live waveform source; +- the active runtime publisher rate; +- the active configuration revision; +- an independent diagnostic sample counter. + +The tested Windows USB-Ethernet capture path reliably shows the broadcast mirror but does not reliably expose the canonical multicast/priority-tagged stream. The mirror therefore exists only as a visibility/debug aid. + +An analyzer showing `AR_DIAG_SV1` is observing the diagnostic mirror, not the configured canonical SCL identity. + +## Synchronization truth + +The current firmware keeps `smpSynch=0` because no measured PTP-disciplined synchronization has yet been established. -The diagnostic identity is intentionally different from the canonical stream so analyzers do not merge both sequences. This stream is a capture/debug aid, not a process-bus compliance target. +A publisher rate, stable waveform or software task timestamp must never be converted into a synchronization claim. ## Board wiring used @@ -82,82 +153,78 @@ The diagnostic identity is intentionally different from the canonical stream so ## Toolchain -Use ESP-IDF 5.5.x. If the board contains an early ESP32-P4 engineering sample, select the matching chip revision configuration before flashing. For a detected ESP32-P4 v1.x/v2.x board, enable the ESP-IDF pre-v3 chip-revision option and rebuild. Do not bypass a revision mismatch with `--force`. +Use ESP-IDF 5.5.x. -The currently tested board is ESP32-P4 rev 1.3, so its local `sdkconfig` must retain the pre-v3 revision setting. +The currently tested board is ESP32-P4 rev 1.3, so its local `sdkconfig` must retain the pre-v3 chip-revision setting required by that silicon revision. + +Do not use `fullclean`, delete a working `sdkconfig`, or change target/revision settings merely to repeat a normal build. ## Build and flash ```bash cd embedded/esp32p4_smv_injector idf.py build -idf.py -p flash monitor +idf.py -p flash +idf.py -p monitor ``` -For the tested bench the port is currently `COM3`. +For the current bench the serial port has been `COM3`. -After boot, expected serial output includes: +A current SCL-profile-bridge boot includes output similar to: ```text -P2 waveform: 50 Hz, 80 samples/cycle, 4000 fps, period=250 us -SCL canonical: svID=MU01_SV1 ... APPID=0x4000 ... -LAB mirror: svID=AR_DIAG_SV1 APPID=0x4F01 ... explicit smpRate=4000 smpMod=SmpPerSec -P2 timing: samples=... MC ok=... fail=... mirror ok=... fail=... missed=... wake_late_us[min/mean/max]=... +Live signal engine ready: profile=... rate=... fps ...; use GUI START +GUI profile deployment uses bounded PROFILE subcommands while STOPPED. ``` -## Capture / analyzer checks - -For the current host capture adapter, use the diagnostic stream first: - -```text -eth.type == 0x88ba -``` +After the GUI deploys a profile and START is requested, the publisher reports an armed profile and begins rate-specific telemetry. -Expected diagnostic behavior: +## Host control path -- roughly 4000 decoded SV frames/s; -- continuous 16-bit `smpCnt` progression; -- visible non-zero three-phase current and voltage samples; -- reconstructed 80 samples/cycle at 50 Hz; -- balanced A/B/C waveform and phasors separated by 120 degrees. - -The standards-facing multicast stream is sent in parallel. A trusted capture path should validate: +The normal operator path is the local GUI: ```text -eth.dst == 01:0c:cd:04:00:01 -vlan.etype == 0x88ba +Import SCL / CID + -> host C++ profile compiler + -> Class A/B/C diagnostics + -> Deploy while STOPPED + -> START + -> live value changes + -> STOP ``` -Do not use host USB-Ethernet arrival jitter as the final device timing metric. The next P2 timing gate is ESP32-P4 EMAC IEEE1588v2 hardware TX timestamps and/or an independent reference capture path. - -## Configuration +The serial command parser remains a low-level bench/fallback transport and is not the primary operator UX. -Run `idf.py menuconfig` and open **ARStack ESP32-P4 SMV Injector** to control: +## Remaining validation and hardening work -- live TX; -- P2 50 Hz realtime mode; -- canonical VLAN PCP4/VID0; -- lab-only untagged diagnostic stream; -- optional `0x88B5` capture probes; -- current RMS in mA; -- voltage RMS in mV. +The injector functional phase is complete for the current supported 4I+4V profile. Remaining work is validation depth and profile breadth: -Use an isolated bench network or a direct capture/TAP connection. Do not connect this development image to an operational substation process bus. +- trusted raw capture of the canonical multicast/VLAN stream; +- independent configured-vs-observed profile comparison; +- ESP32-P4 EMAC hardware TX timestamp evidence; +- hardware timing statistics over retained long-duration runs; +- IEC/IEEE 61850-9-3 PTP clock discipline before changing `smpSynch`; +- broader SCL DataSet/basic-type deployment support; +- engineering scaling derived from validated source information rather than generic assumptions; +- multi-stream publication; +- fault/scenario/COMTRADE workflows; +- relay/subscriber interoperability evidence; +- formal profile/conformance work using sourced normative rules and independent test evidence. -## Remaining P2 acceptance work +## Next hardware validation node -Before calling the publisher production-grade: +A second ESP32-P4 Ethernet board is a suitable candidate for an independent raw analyzer: -- validate 60 seconds of 4000 fps capture with zero unexplained `smpCnt` gaps; -- enable ESP32-P4 EMAC IEEE1588v2 hardware time and collect hardware TX timestamp evidence; -- calculate scheduled-vs-actual TX min/max/mean/P95/P99 timing error; -- validate canonical VLAN multicast on a trusted capture path; -- add 60 Hz / 4800 fps scheduling; -- add IEC/IEEE 61850-9-3 PTP synchronization before changing `smpSynch` from 0; -- move from hard-coded bench constants to an SCL/profile-compiled runtime configuration; -- validate exact IEC 61850-9-2 / IEC 61869-9 / legacy 9-2LE behavior against sourced profile rules and independent relay/tool testing. +```text +Ethernet RX + -> promiscuous / all-multicast receive + -> preserve and parse VLAN tags + -> classify 0x88BA + -> decode SV identity and ASDU + -> verify smpCnt continuity / rate / quality + -> compare observed wire facts to configured SCL facts +``` -Tracked work: +In a switched lab, the analyzer still requires the relevant frames to reach its physical port. Use a TAP or managed-switch mirror/SPAN configuration that preserves the traffic and VLAN tags. -- P2 timing evidence: issue #21 -- standards-first SCL/profile/PTP engine: issue #24 \ No newline at end of file +Use an isolated bench network. Do not connect this development image to an operational process bus. \ No newline at end of file From 3186756393d6f8b721b53c161a5478d97f2359d7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 14:51:04 +0700 Subject: [PATCH 36/36] docs: update SMV architecture after SCL-driven injector acceptance --- embedded/esp32p4_smv_injector/ARCHITECTURE.md | 300 ++++++++++++------ 1 file changed, 208 insertions(+), 92 deletions(-) diff --git a/embedded/esp32p4_smv_injector/ARCHITECTURE.md b/embedded/esp32p4_smv_injector/ARCHITECTURE.md index e83b68204..2d403af5c 100644 --- a/embedded/esp32p4_smv_injector/ARCHITECTURE.md +++ b/embedded/esp32p4_smv_injector/ARCHITECTURE.md @@ -1,116 +1,180 @@ -# ESP32-P4 SV Injector — standards-first architecture +# ESP32-P4 Sampled Values Injector — standards-first architecture -This document defines the direction after P0/P1 hardware bring-up and P2 50 Hz realtime publishing proved that the current ESP32-P4 target can sustain a 4000-sample/s SV stream on the bench. +This document defines the architecture after the ESP32-P4 injector progressed from fixed-rate bring-up to a physically exercised SCL-driven runtime profile. + +The core injector workflow is now functionally complete for the current supported 4I+4V embedded profile. Remaining work is deliberately separated into validation depth, profile breadth, synchronization, analyzer and product-hardening phases. The design separates: - **wire facts** — what is actually observed or timestamped; - **configured expectations** — what SCL/profile configuration requires; -- **profile claims** — conclusions such as generic IEC 61850-9-2, IEC 61869-9, or legacy 9-2LE compatibility; +- **runtime state** — what immutable profile and mutable signal state the ESP32-P4 has actually armed; +- **profile claims** — conclusions such as generic IEC 61850-9-2, IEC 61869-9, or legacy profile compatibility; - **synchronization evidence** — what is actually known about PTP clock and `smpSynch` state. -A decoded SV packet is not by itself a conformance claim. +A decoded SV packet or successful bench run is not by itself a conformance claim. -## 1. Product split +## 1. Current product split ```text Host engineering application | - | SCD/CID/ICD/IID + waveform/fault scenario + | SCD/CID/ICD/IID + operator values v -SCL/profile compiler +C++ SCL/profile compiler |- stream address and identifiers |- SmvOpts field-presence policy - |- dataset order and element types - |- sample-rate / nofASDU / counter policy - |- scaling and quality policy - |- synchronization policy + |- DataSet order and element types + |- sample-rate / nofASDU / counter-policy candidates + |- compatibility Class A/B/C diagnostics v -Versioned compiled SvPublisherProfile +Validated immutable SvPublisherProfile | - | control plane + | bounded control plane v ESP32-P4 realtime runtime - |- validate immutable profile + |- validate / commit immutable profile while STOPPED |- prebuild packet templates - |- waveform/fault engine + |- fixed-point waveform engine |- deterministic SV publisher - |- PTP-disciplined hardware clock - |- truthful smpSynch + |- live magnitude / phase / frequency / quality state + |- truthful unsynchronized state until PTP evidence exists |- timing/evidence telemetry ``` XML/SCL parsing belongs on the host. It must never occur in the ESP32 realtime TX hot path. -## 2. Reuse ARStack core instead of creating parallel models +## 2. Proven end-to-end injector flow + +The current physically exercised flow is: + +```text +engineering file + -> C++ SCL parser + -> resolved SV stream + -> Class A after required counter-policy confirmation + -> Deploy while STOPPED + -> ESP32-P4 profile commit + -> START + -> 4800 fps runtime publication + -> live 50 Hz three-phase values +``` + +A retained real engineering-file-derived test reached approximately 4800 samples/s with zero canonical TX failures and zero missed slots in device telemetry. + +This establishes functional completion for the current embedded profile boundary, not universal SCL support or formal conformance. -ARStack already contains: +## 3. Reuse ARStack core instead of creating parallel models -- `ariec61850::scl::SclDocument` / `SclSampledValuesStream`; -- dataset entries with CDC/basic-type metadata; -- Ethernet/VLAN types; -- Sampled Values ASDU/PDU/frame codecs; -- sample-counter and stream-supervision utilities. +ARStack contains the shared SCL, Ethernet, SV and evidence primitives required by this architecture. The host compiler should remain the semantic authority rather than introducing a second browser or embedded SCL model. -The future publisher profile should be compiled from those models rather than duplicating SCL parsing inside the embedded application. +The browser is therefore an operator/control surface, not an IEC 61850 semantic parser. -## 3. Compiled SV profile +## 4. Compiled SV profile -The host should reduce SCL plus an explicitly selected interoperability profile to a compact immutable structure similar to: +The host reduces SCL plus explicit engineering resolution to an immutable profile containing the fields needed by the embedded runtime, including: ```text SvPublisherProfile - version - profileFamily + schemaVersion + controlBlockReference + svId + dataSetReference destinationMac appId - vlanId / vlanPriority - svId + vlanPresent / vlanId / vlanPriority confRev + sampleRate / sampleMode + publisherRateHz nofAsdu - fieldPresence - dataSet - refrTm - smpRate - smpMod - dataSetReference - samplingBasis - sampleRate - samplesPerCycle - sampleCounterPolicy - synchronizationPolicy - channels[] - semantic - basicType - byteOffset - scale - qualityPolicy + sampleCounterPolicy / modulus + ASDU field-presence policy + ordered channels + payloadSizeBytes ``` -Activation must fail closed when required bindings are unresolved or the binary profile fails version/length/checksum validation. +The first embedded bridge intentionally supports a bounded subset: + +- one ASDU; +- absolute `SmpPerSec` publisher rate; +- explicit validated sample-counter modulus; +- eight logical channels `Ia, Ib, Ic, In, Ua, Ub, Uc, Un`; +- 16 ordered leaves as eight `INT32 value + Quality` pairs; +- 64-byte sample payload. + +Activation fails closed when required bindings are unresolved or the profile cannot be mapped safely to this layout. -## 4. Profile claims +## 5. Runtime mutability law + +While RUNNING, profile identity/layout is immutable: + +```text +immutable while RUNNING + destination MAC + APPID + VLAN / PCP + svID + DataSet identity/layout + publisher rate + sample-counter policy + ASDU field-presence policy +``` + +Live signal state remains mutable: + +```text +mutable while RUNNING + current / voltage magnitude + phase + signal frequency + quality + channel enable state +``` + +Structural change therefore requires: + +```text +STOP -> validate -> deploy -> arm -> START +``` -Keep three product-facing families separate: +## 6. Canonical versus diagnostic traffic -1. **Generic IEC 61850-9-2 / SCL driven** — exact configuration follows the selected SCL stream. -2. **IEC 61869-9** — only after required constraints are sourced and validated. -3. **Legacy 9-2LE compatibility** — explicit compatibility mode, never silently inferred as the generic standard. +The standards-facing canonical stream and the capture workaround have separate identities. -Unknown or partially evidenced profile fields stay unknown. The software must not convert a heuristic into a compliance label. +### Canonical stream -## 5. Canonical vs diagnostic traffic +The canonical stream uses the active SCL-derived runtime profile: multicast destination, APPID, VLAN/PCP, `svID`, `confRev`, publisher rate and sample-counter policy. -The standards-facing canonical stream and any capture workaround must have separate identities. +### Diagnostic mirror -Current bench example: +The current lab mirror is intentionally different: -- canonical: SCL-derived multicast/VLAN stream; -- diagnostic mirror: untagged broadcast with its own APPID/svID and explicit sampling metadata for raw analyzer reconstruction. +- broadcast destination; +- APPID `0x4F01`; +- `svID=AR_DIAG_SV1`; +- untagged; +- same live waveform source; +- active publisher rate and configuration revision; +- separate diagnostic sample counter. -A diagnostic mirror is never evidence that the multicast/VLAN process-bus stream has passed interoperability testing. +The current Windows USB-Ethernet capture path reliably exposes the mirror but does not reliably expose the canonical multicast/priority-tagged stream. Therefore observing `AR_DIAG_SV1` is evidence of the diagnostic path only; it is not evidence that the SCL profile was ignored. -## 6. Timing hierarchy +The diagnostic mirror must never be relabelled with the canonical `svID`, because that would make a debug frame indistinguishable from the standards-facing stream. + +## 7. Rational scheduling + +The runtime uses a 1 MHz GPTimer with a rational one-shot schedule cursor. + +```text +4000 fps -> 250 / 250 / 250 ... us +4800 fps -> 208 / 208 / 209 ... us +``` + +This avoids permanently rounding a fractional-microsecond target into the wrong long-term publisher rate. + +The packet template is prebuilt when the profile is armed. The hot loop only patches bounded fixed-width fields and transmits the frame. + +## 8. Timing hierarchy Timing evidence has distinct layers and they must not be mixed: @@ -123,64 +187,116 @@ sample deadline -> host capture timestamp ``` -The current GPTimer/FreeRTOS telemetry proves scheduler continuity, but host USB-Ethernet arrival timestamps can be batched or perturbed and are not the source of truth for device TX jitter. +Current GPTimer/FreeRTOS telemetry proves scheduler continuity and device-side submission behavior. It does not establish final on-wire jitter. -P2 should therefore add ESP32-P4 EMAC IEEE1588v2 hardware timestamp evidence before making on-wire timing claims. +The next timing gate remains ESP32-P4 EMAC hardware TX timestamp evidence and/or an independent hardware analyzer. -## 7. PTP / IEC 61850-9-3 direction +## 9. PTP / IEC 61850-9-3 direction -Use an evidence-oriented layered design and move standards-facing synchronization onto hardware time: +Use an evidence-oriented layered design and move synchronization claims onto hardware time: 1. PTPv2 Layer-2 codec (`0x88F7`). 2. Passive monitor: observed sources, domain, message types, sequence integrity. -3. IEC/IEEE 61850-9-3 Power Profile validator. +3. Applicable power-profile validator. 4. ESP32-P4 EMAC IEEE1588v2 hardware clock. 5. Peer-delay / offset estimation and clock servo. 6. Lock / holdover / unlocked state machine. 7. SV `smpSynch` policy derived from measured state. 8. Hardware TX timestamp evidence correlated with `smpCnt`. -Software-generated lab PTP traffic must never be treated as proof of hardware synchronization. Traffic visibility, clock discipline and synchronization claims remain separate. - -## 8. `smpSynch` rule +Software-generated traffic or stable waveform output must never be treated as proof of synchronization. -Standards mode: +## 10. `smpSynch` rule ```text no valid synchronization evidence -> unsynchronized -measured local synchronization -> local according to selected profile -measured global/PTP synchronization-> global according to selected profile +measured synchronization -> encode only what the selected profile and evidence justify +``` + +The current injector therefore keeps `smpSynch=0`. + +## 11. Independent analyzer node + +The preferred next hardware phase is a second raw Ethernet analyzer node, potentially using another ESP32-P4 board: + +```text +Process-bus Ethernet + | + v +ESP32-P4 Analyzer + |- promiscuous / all-multicast raw RX + |- preserve / parse IEEE 802.1Q tags + |- classify SV EtherType 0x88BA + |- decode APPID / svID / smpCnt / confRev / quality / sample values + |- stream table and continuity statistics + |- timing evidence + | + v +Observed-wire profile + | + v +Configured-vs-observed comparison ``` -Compatibility overrides, if later offered for relay readability tests, must be visibly labelled as compatibility behavior and never treated as timing evidence. +Promiscuous mode cannot recover frames that a switch never sends to the analyzer port. A switched lab therefore requires a trusted TAP or mirror/SPAN port that preserves the relevant traffic and VLAN tags. + +This analyzer removes ambiguity introduced by host USB-NIC filtering or VLAN handling and enables direct comparison of configured SCL truth with observed wire truth. + +## 12. Profile claims + +Keep product-facing claims separate: -## 9. Current bench interpretation +1. **Generic SCL-driven Sampled Values** — exact configuration follows the selected engineering stream within the implemented runtime boundary. +2. **IEC 61869-9 profile support** — only after the applicable constraints are sourced and independently validated. +3. **Legacy profile compatibility** — explicit compatibility behavior only, never silently inferred as the generic standard. -The current 50 Hz build has demonstrated: +Unknown or partially evidenced profile fields stay unknown. A heuristic must not become a compliance label. -- approximately 4000 SV samples/s; -- continuous sequence on the diagnostic stream; -- balanced reconstructed 50 Hz three-phase waveforms; -- zero device-side TX failures and missed GPTimer notifications in observed stable runs; -- a host USB-Ethernet capture path that reports about the correct average throughput but large individual arrival-time excursions. +## 13. Current completion boundary -Therefore the next timing work is **hardware timestamp evidence**, not compensating the publisher against host capture jitter. +### Functionally complete -## 10. Acceptance gates +- host SCL/CID import; +- C++ semantic compilation; +- stream discovery; +- Class A/B/C diagnostics; +- explicit counter-policy confirmation; +- bounded immutable profile deployment; +- ESP32-P4 runtime profile arm; +- 4000 and 4800 fps runtime scheduling; +- live current/voltage magnitude, phase, frequency, quality and enable control; +- START/STOP state model; +- device telemetry for rate, TX failure, missed slots and generation; +- separate diagnostic mirror for capture-path troubleshooting. -A production-grade result requires independent gates for: +### Follow-on phases + +- trusted canonical multicast/VLAN on-wire proof; +- configured-vs-observed analyzer; +- hardware TX timing statistics; +- PTP-disciplined clock and synchronization evidence; +- broader embedded DataSet/type support; +- validated engineering scaling; +- multi-stream publication; +- fault/scenario and COMTRADE workflows; +- independent relay/subscriber interoperability; +- production packaging and qualification; +- formal conformance work. + +These follow-on gates do not reopen the already proven core injector workflow. + +## 14. Acceptance philosophy + +A production-grade result ultimately requires independent evidence for: - packet/framing correctness; - SCL/configuration match; -- dataset semantics and engineering scaling; +- DataSet semantics and engineering scaling; - canonical multicast/VLAN interoperability; -- sustained 4000/4800 sample/s integrity; +- sustained publisher integrity; - hardware TX timing statistics; - PTP synchronization quality and truthful `smpSynch`; -- relay/subscriber interoperability; -- evidence artifacts (PCAP + machine-readable timing/profile report). - -Tracked work: +- subscriber interoperability; +- retained evidence artifacts. -- P2 deterministic timing evidence: issue #21 -- standards-first SCL/profile/PTP architecture: issue #24 \ No newline at end of file +The project should continue to distinguish **functional completion**, **interoperability evidence**, and **formal conformance** rather than treating them as the same milestone. \ No newline at end of file