diff --git a/.github/workflows/smv-injector-gui.yml b/.github/workflows/smv-injector-gui.yml new file mode 100644 index 000000000..1a96d3934 --- /dev/null +++ b/.github/workflows/smv-injector-gui.yml @@ -0,0 +1,114 @@ +name: SMV Injector GUI + +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: + profile-bridge-smoke: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + + - 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 + 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/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 + 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 backend smoke + shell: bash + run: | + 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 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 + + windows-launcher-smoke: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - 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 + + - 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' + } diff --git a/CMakeLists.txt b/CMakeLists.txt index 44a014ab5..576c9c51e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -170,6 +170,13 @@ if(ARIEC61850_BUILD_TESTS) add_test(NAME ariec61850_${test_name}_tests COMMAND ariec61850_${test_name}_tests) endforeach() + add_executable(ariec61850_live_signal_tests tests/test_live_signal.cpp) + target_link_libraries(ariec61850_live_signal_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_live_signal_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_live_signal_tests) + ariec61850_apply_sanitizers(ariec61850_live_signal_tests) + add_test(NAME ariec61850_live_signal_tests COMMAND ariec61850_live_signal_tests) + add_executable(ariec61850_goose_runtime_tests tests/test_goose_runtime.cpp) target_link_libraries(ariec61850_goose_runtime_tests PRIVATE ARIEC61850::core) target_compile_features(ariec61850_goose_runtime_tests PRIVATE cxx_std_20) @@ -370,4 +377,4 @@ if(ARIEC61850_BUILD_FUZZERS) ariec61850_add_fuzzer(ariec61850_fuzz_mms_services fuzz/fuzz_mms_services.cpp) ariec61850_add_fuzzer(ariec61850_fuzz_mms_file_service fuzz/fuzz_mms_file_service.cpp) ariec61850_add_fuzzer(ariec61850_fuzz_reporting fuzz/fuzz_reporting.cpp) -endif() +endif() \ No newline at end of file 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. 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. diff --git a/apps/smv_injector_gui/README.md b/apps/smv_injector_gui/README.md new file mode 100644 index 000000000..ee5a42dd1 --- /dev/null +++ b/apps/smv_injector_gui/README.md @@ -0,0 +1,129 @@ +# ARStack61850 SMV Injector GUI + +Operator-facing control plane for the ESP32-P4 Sampled Values injector. + +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 + +```text +Import SCL / CID / SCD / IID + -> C++ SCL parser + -> normalized SvPublisherProfile + -> select SV stream + -> 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 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 + +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: + +- 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; +- 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. + +## Counter-policy rule + +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. + +## Engineering scaling + +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. +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 +``` + +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 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 + +```text +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 +``` + +Profile identity/layout changes require `STOP -> validate -> deploy -> arm`. Live signal values do not rebuild BER and do not restart `smpCnt`. + +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 diff --git a/apps/smv_injector_gui/app.js b/apps/smv_injector_gui/app.js new file mode 100644 index 000000000..eeedfe5dd --- /dev/null +++ b/apps/smv_injector_gui/app.js @@ -0,0 +1,495 @@ +"use strict"; + +const CHANNELS = [ + { id: "IA", kind: "current", label: "Current A", magnitude: 1.0, phase: 0, color: "#ff5f6d" }, + { id: "IB", kind: "current", label: "Current B", magnitude: 1.0, phase: -120, color: "#f2bb4c" }, + { id: "IC", kind: "current", label: "Current C", magnitude: 1.0, phase: 120, color: "#42a5f5" }, + { id: "IN", kind: "current", label: "Current N", magnitude: 0.0, phase: 0, color: "#a88cff" }, + { id: "UA", kind: "voltage", label: "Voltage A", magnitude: 57.74, phase: 0, color: "#ff5f6d" }, + { id: "UB", kind: "voltage", label: "Voltage B", magnitude: 57.74, phase: -120, color: "#f2bb4c" }, + { id: "UC", kind: "voltage", label: "Voltage C", magnitude: 57.74, phase: 120, color: "#42a5f5" }, + { id: "UN", kind: "voltage", label: "Voltage N", magnitude: 0.0, phase: 0, color: "#a88cff" }, +]; + +const state = { + port: null, + reader: null, + readLoopActive: false, + connected: false, + running: false, + liveApply: true, + frequencyHz: 50, + channels: new Map(CHANNELS.map((channel) => [channel.id, { ...channel, enabled: true, quality: 0 }])), + sendChain: Promise.resolve(), +}; + +const els = {}; +let toastTimer = null; + +function $(id) { return document.getElementById(id); } + +function initElements() { + [ + "deviceStatus", "deviceStatusText", "connectButton", "runState", "runStateText", + "startButton", "stopButton", "fpsMetric", "missedMetric", "failMetric", + "generationMetric", "frequencyInput", "frequencySlider", "liveApplyToggle", + "balancedButton", "zeroButton", "applyAllButton", "channelGrid", "runtimeLog", + "clearLogButton", "phasorCanvas", "toast", "sclFile", "sclState" + ].forEach((id) => { els[id] = $(id); }); +} + +function wireCountFor(channel) { + if (channel.kind === "current") return Math.round(channel.magnitude * 1000); + return Math.round(channel.magnitude * 100); +} + +function phaseMdeg(channel) { return Math.round(channel.phase * 1000); } +function frequencyMhz() { return Math.round(state.frequencyHz * 1000); } + +function showToast(message, error = false) { + clearTimeout(toastTimer); + els.toast.textContent = message; + els.toast.classList.toggle("error", error); + els.toast.classList.add("show"); + toastTimer = setTimeout(() => els.toast.classList.remove("show"), 2400); +} + +function logLine(text, direction = "rx") { + const now = new Date(); + const stamp = now.toLocaleTimeString([], { hour12: false }); + const prefix = direction === "tx" ? "→" : direction === "ui" ? "•" : "←"; + if (els.runtimeLog.textContent === "Waiting for device connection…") els.runtimeLog.textContent = ""; + els.runtimeLog.textContent += `[${stamp}] ${prefix} ${text}\n`; + if (els.runtimeLog.textContent.length > 60000) { + els.runtimeLog.textContent = els.runtimeLog.textContent.slice(-48000); + } + els.runtimeLog.scrollTop = els.runtimeLog.scrollHeight; +} + +function setConnected(connected) { + state.connected = connected; + els.deviceStatus.dataset.state = connected ? "online" : "offline"; + els.deviceStatusText.textContent = connected ? "Device connected" : "Device offline"; + els.connectButton.textContent = connected ? "Disconnect" : "Connect device"; + els.startButton.disabled = !connected || state.running; + els.stopButton.disabled = !connected || !state.running; + els.zeroButton.disabled = !connected; + els.applyAllButton.disabled = !connected; + document.querySelectorAll(".channel-apply").forEach((button) => { button.disabled = !connected; }); +} + +function setRunning(running) { + state.running = running; + els.runState.dataset.state = running ? "running" : "stopped"; + els.runStateText.textContent = running ? "RUNNING" : "STOPPED"; + els.startButton.disabled = !state.connected || running; + els.stopButton.disabled = !state.connected || !running; +} + +function buildChannels() { + els.channelGrid.innerHTML = ""; + for (const source of CHANNELS) { + const channel = state.channels.get(source.id); + const unit = channel.kind === "current" ? "A RMS" : "V RMS"; + const magStep = channel.kind === "current" ? "0.001" : "0.01"; + const magMax = channel.kind === "current" ? "10000" : "1000000"; + + const card = document.createElement("article"); + card.className = "channel-card"; + card.dataset.channel = channel.id; + card.dataset.enabled = String(channel.enabled); + card.style.setProperty("--phase-color", channel.color); + card.innerHTML = ` +
+
+ +
${channel.id}${channel.label}
+
+ +
+
+
+ +
+ + ${unit} +
+
+
+ +
+ + deg +
+
+
+ + `; + + els.channelGrid.appendChild(card); + bindChannelCard(card, channel.id); + updateWirePreview(card, channel); + } +} + +function readCardValues(card, id) { + const channel = state.channels.get(id); + const magnitude = Number(card.querySelector(".magnitude-input").value); + const phase = Number(card.querySelector(".phase-input").value); + const enabled = card.querySelector(".channel-enable").checked; + if (!Number.isFinite(magnitude) || magnitude < 0 || !Number.isFinite(phase)) return false; + channel.magnitude = magnitude; + channel.phase = phase; + channel.enabled = enabled; + card.dataset.enabled = String(enabled); + updateWirePreview(card, channel); + drawPhasors(); + return true; +} + +function updateWirePreview(card, channel) { + card.querySelector(".wire-preview").textContent = `${wireCountFor(channel)} counts · ${phaseMdeg(channel)} mdeg`; +} + +function bindChannelCard(card, id) { + const magnitude = card.querySelector(".magnitude-input"); + const phase = card.querySelector(".phase-input"); + const slider = card.querySelector(".phase-slider"); + const enable = card.querySelector(".channel-enable"); + const apply = card.querySelector(".channel-apply"); + + const schedule = debounce(async () => { + if (!readCardValues(card, id)) return; + if (state.liveApply && state.connected) await applyChannel(id); + }, 130); + + magnitude.addEventListener("input", schedule); + phase.addEventListener("input", () => { + const value = Number(phase.value); + if (Number.isFinite(value) && value >= -180 && value <= 180) slider.value = String(value); + schedule(); + }); + slider.addEventListener("input", () => { + phase.value = Number(slider.value).toFixed(1).replace(/\.0$/, ""); + schedule(); + }); + enable.addEventListener("change", async () => { + if (!readCardValues(card, id)) return; + if (state.connected) await sendCommand(`ENABLE ${id} ${state.channels.get(id).enabled ? 1 : 0}`); + }); + apply.addEventListener("click", async () => { + if (!readCardValues(card, id)) return; + await applyChannel(id); + }); +} + +function debounce(fn, delay) { + let timer = null; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), delay); + }; +} + +async function sendCommand(command) { + if (!state.connected || !state.port?.writable) { + showToast("Connect the device first", true); + return false; + } + state.sendChain = state.sendChain.then(async () => { + const writer = state.port.writable.getWriter(); + try { + await writer.write(new TextEncoder().encode(`${command}\n`)); + logLine(command, "tx"); + } finally { + writer.releaseLock(); + } + }).catch((error) => { + logLine(`Serial write failed: ${error.message}`, "ui"); + showToast(`Serial write failed: ${error.message}`, true); + }); + await state.sendChain; + return true; +} + +async function applyChannel(id) { + const channel = state.channels.get(id); + return sendCommand(`SET ${id} ${wireCountFor(channel)} ${phaseMdeg(channel)} ${channel.quality >>> 0}`); +} + +async function applyAll() { + for (const source of CHANNELS) await applyChannel(source.id); + await sendCommand(`FREQ ${frequencyMhz()}`); + showToast("All live signal values applied"); +} + +async function connectSerial() { + if (!("serial" in navigator)) { + showToast("This browser does not expose the Web Serial API", true); + logLine("Web Serial API unavailable. Use a compatible browser on localhost.", "ui"); + return; + } + + try { + state.port = await navigator.serial.requestPort(); + await state.port.open({ baudRate: 115200, dataBits: 8, stopBits: 1, parity: "none", flowControl: "none" }); + setConnected(true); + logLine("Serial device connected at 115200 baud", "ui"); + startReadLoop(); + await sendCommand("SHOW"); + } catch (error) { + if (error.name !== "NotFoundError") { + showToast(`Connection failed: ${error.message}`, true); + logLine(`Connection failed: ${error.message}`, "ui"); + } + } +} + +async function disconnectSerial() { + try { + state.readLoopActive = false; + if (state.reader) { + try { await state.reader.cancel(); } catch (_) { /* no-op */ } + } + if (state.port) await state.port.close(); + } catch (error) { + logLine(`Disconnect warning: ${error.message}`, "ui"); + } finally { + state.reader = null; + state.port = null; + setConnected(false); + setRunning(false); + els.fpsMetric.textContent = "—"; + els.missedMetric.textContent = "—"; + els.failMetric.textContent = "—"; + showToast("Device disconnected"); + } +} + +async function startReadLoop() { + if (!state.port?.readable || state.readLoopActive) return; + state.readLoopActive = true; + const decoder = new TextDecoder(); + let pending = ""; + + while (state.readLoopActive && state.port?.readable) { + try { + state.reader = state.port.readable.getReader(); + while (state.readLoopActive) { + const { value, done } = await state.reader.read(); + if (done) break; + pending += decoder.decode(value, { stream: true }); + const lines = pending.split(/\r?\n/); + pending = lines.pop() ?? ""; + for (const line of lines) processDeviceLine(line); + } + } catch (error) { + if (state.readLoopActive) logLine(`Serial read failed: ${error.message}`, "ui"); + } finally { + state.reader?.releaseLock(); + state.reader = null; + } + break; + } +} + +function processDeviceLine(raw) { + const line = raw.replace(/\x1b\[[0-9;]*m/g, "").trim(); + if (!line) return; + logLine(line, "rx"); + + if (line.includes("START accepted")) setRunning(true); + if (line.includes("STOP accepted")) setRunning(false); + + const stateMatch = line.match(/state=(RUNNING|STOPPED).*generation=(\d+).*signal_frequency=(\d+)\s*mHz/i); + if (stateMatch) { + setRunning(stateMatch[1].toUpperCase() === "RUNNING"); + els.generationMetric.textContent = stateMatch[2]; + const hz = Number(stateMatch[3]) / 1000; + if (Number.isFinite(hz)) setFrequencyUi(hz, false); + } + + const timing = line.match(/samples=(\d+)\s+\(~(\d+)\s+fps\).*?MC ok=(\d+) fail=(\d+).*?missed=(\d+).*?signal_gen=(\d+)/i); + if (timing) { + els.fpsMetric.textContent = `${timing[2]} fps`; + els.failMetric.textContent = timing[4]; + els.missedMetric.textContent = timing[5]; + els.generationMetric.textContent = timing[6]; + } + + const committed = line.match(/Live signal generation\s+(\d+)\s+committed/i); + if (committed) els.generationMetric.textContent = committed[1]; +} + +function setFrequencyUi(hz, transmit = true) { + state.frequencyHz = hz; + els.frequencyInput.value = hz.toFixed(3); + if (hz >= 45 && hz <= 65) els.frequencySlider.value = String(hz); + if (transmit && state.liveApply && state.connected) { + sendCommand(`FREQ ${frequencyMhz()}`); + } +} + +function setBalanced() { + const values = { + IA: [1.0, 0], IB: [1.0, -120], IC: [1.0, 120], IN: [0, 0], + UA: [57.74, 0], UB: [57.74, -120], UC: [57.74, 120], UN: [0, 0], + }; + for (const [id, [magnitude, phase]] of Object.entries(values)) { + const channel = state.channels.get(id); + channel.magnitude = magnitude; + channel.phase = phase; + channel.enabled = true; + } + buildChannels(); + drawPhasors(); + if (state.connected) applyAll(); +} + +async function zeroOutputs() { + for (const channel of state.channels.values()) channel.magnitude = 0; + buildChannels(); + drawPhasors(); + if (state.connected) await sendCommand("ZERO"); +} + +function drawPhasors() { + const canvas = els.phasorCanvas; + const ctx = canvas.getContext("2d"); + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + const width = Math.max(300, Math.floor(rect.width * dpr)); + const height = Math.max(220, Math.floor(rect.height * dpr)); + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + ctx.clearRect(0, 0, width, height); + ctx.save(); + ctx.scale(dpr, dpr); + const w = width / dpr; + const h = height / dpr; + const cx = w / 2; + const cy = h / 2; + const radius = Math.min(w, h) * 0.36; + + ctx.strokeStyle = "rgba(143,166,183,.16)"; + ctx.lineWidth = 1; + for (const factor of [0.33, 0.66, 1]) { + ctx.beginPath(); ctx.arc(cx, cy, radius * factor, 0, Math.PI * 2); ctx.stroke(); + } + ctx.beginPath(); ctx.moveTo(cx - radius - 20, cy); ctx.lineTo(cx + radius + 20, cy); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(cx, cy - radius - 20); ctx.lineTo(cx, cy + radius + 20); ctx.stroke(); + + const groups = [ + ["IA", "IB", "IC"], + ["UA", "UB", "UC"], + ]; + groups.forEach((ids, groupIndex) => { + const maxMagnitude = Math.max(0.0001, ...ids.map((id) => state.channels.get(id).magnitude)); + ids.forEach((id) => { + const channel = state.channels.get(id); + if (!channel.enabled || channel.magnitude <= 0) return; + const angle = channel.phase * Math.PI / 180; + const visualScale = 0.38 + 0.62 * Math.min(1, channel.magnitude / maxMagnitude); + const length = radius * visualScale * (groupIndex === 0 ? 0.86 : 1); + const x = cx + Math.cos(angle) * length; + const y = cy - Math.sin(angle) * length; + ctx.strokeStyle = channel.color; + ctx.fillStyle = channel.color; + ctx.globalAlpha = groupIndex === 0 ? 0.75 : 1; + ctx.lineWidth = groupIndex === 0 ? 2 : 3; + ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(x, y); ctx.stroke(); + const head = 8; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x - Math.cos(angle - 0.45) * head, y + Math.sin(angle - 0.45) * head); + ctx.lineTo(x - Math.cos(angle + 0.45) * head, y + Math.sin(angle + 0.45) * head); + ctx.closePath(); ctx.fill(); + ctx.font = "600 11px system-ui"; + ctx.fillText(id, x + 7, y - 6); + ctx.globalAlpha = 1; + }); + }); + + ctx.fillStyle = "rgba(238,246,251,.72)"; + ctx.font = "600 10px system-ui"; + ctx.fillText("0°", cx + radius + 24, cy + 3); + ctx.fillText("+90°", cx + 5, cy - radius - 18); + ctx.fillText("±180°", cx - radius - 52, cy + 3); + ctx.fillText("−90°", cx + 5, cy + radius + 22); + ctx.restore(); +} + +function bindUi() { + els.connectButton.addEventListener("click", () => state.connected ? disconnectSerial() : connectSerial()); + els.startButton.addEventListener("click", () => sendCommand("START")); + els.stopButton.addEventListener("click", () => sendCommand("STOP")); + els.zeroButton.addEventListener("click", zeroOutputs); + els.applyAllButton.addEventListener("click", applyAll); + els.balancedButton.addEventListener("click", setBalanced); + els.clearLogButton.addEventListener("click", () => { els.runtimeLog.textContent = ""; }); + + els.liveApplyToggle.addEventListener("change", () => { + state.liveApply = els.liveApplyToggle.checked; + showToast(state.liveApply ? "Live apply enabled" : "Live apply paused"); + }); + + const frequencyDebounced = debounce(() => { + const hz = Number(els.frequencyInput.value); + if (!Number.isFinite(hz) || hz < 1 || hz > 1000) return; + setFrequencyUi(hz, true); + }, 140); + els.frequencyInput.addEventListener("input", frequencyDebounced); + els.frequencySlider.addEventListener("input", () => { + const hz = Number(els.frequencySlider.value); + setFrequencyUi(hz, true); + }); + + els.sclFile.addEventListener("change", async () => { + const file = els.sclFile.files?.[0]; + if (!file) return; + try { + const text = await file.text(); + const parser = new DOMParser(); + const xml = parser.parseFromString(text, "application/xml"); + if (xml.querySelector("parsererror") || xml.documentElement.localName !== "SCL") { + throw new Error("File is not a well-formed SCL document"); + } + const streamCount = xml.getElementsByTagNameNS("*", "SampledValueControl").length; + els.sclState.textContent = `${file.name} · ${streamCount} SV stream${streamCount === 1 ? "" : "s"} detected`; + showToast("Engineering file loaded locally"); + } catch (error) { + els.sclState.textContent = "Engineering file rejected"; + showToast(error.message, true); + } + }); + + window.addEventListener("resize", debounce(drawPhasors, 80)); + window.addEventListener("beforeunload", () => { + if (state.port) disconnectSerial(); + }); +} + +function init() { + initElements(); + buildChannels(); + bindUi(); + setConnected(false); + setRunning(false); + setFrequencyUi(50, false); + requestAnimationFrame(drawPhasors); + + if (!("serial" in navigator)) { + els.deviceStatusText.textContent = "Web Serial unavailable"; + logLine("Open this localhost GUI in a browser that supports Web Serial.", "ui"); + } +} + +init(); 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) diff --git a/apps/smv_injector_gui/host_server.py b/apps/smv_injector_gui/host_server.py new file mode 100644 index 000000000..e047e077d --- /dev/null +++ b/apps/smv_injector_gui/host_server.py @@ -0,0 +1,185 @@ +#!/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 +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): + 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() + + ensure_runtime_assets() + + 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()) diff --git a/apps/smv_injector_gui/index.html b/apps/smv_injector_gui/index.html new file mode 100644 index 000000000..a0979df64 --- /dev/null +++ b/apps/smv_injector_gui/index.html @@ -0,0 +1,95 @@ + + + + + + + ARStack61850 · SMV Injector + + + + + +
+
+
+ +
ARStack61850

SMV Injector

+
+
+
Device offline
+ +
+
+ +
+
+
+
+
Process bus stream

Active publisher profile

+ Development profile +
+
+
svIDMU01_SV1
+
+
APPID0x4000
+
Rate4000 fps
+
smpCntwrap 4000
+
VLANPCP 4 · VID 0
+
+
+
+ +
No engineering file loaded
+
+

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

+
+ +
+
Publisher state
STOPPED
+
+
Observed rate—
+
Missed slots—
+
TX failures—
+
Signal generation—
+
+
+
+
+ +
+
+
Signal frequency
Hz
+ +
+
+ + + + +
+
+ +
+
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

+ +
+
+
Device evidence

Runtime log

+
Waiting for device connection…
+
+
+
+
+
+ + + + + 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; } +} 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(); diff --git a/apps/smv_injector_gui/responsive.css b/apps/smv_injector_gui/responsive.css new file mode 100644 index 000000000..20758e88a --- /dev/null +++ b/apps/smv_injector_gui/responsive.css @@ -0,0 +1,42 @@ +/* Responsive operator layout. Keep all primary controls visible on laptops and split-screen benches. */ +html { min-width: 0 !important; } +body { overflow-x: hidden; } +.app-shell { min-width: 0; } +.workspace { width: 100%; min-width: 0; } +.hero-grid, .control-toolbar, .bottom-grid, .channel-grid { min-width: 0; } + +@media (max-width: 1180px) { + .topbar { padding: 14px 18px; } + .workspace { padding: 18px 18px 32px; } + .hero-grid { grid-template-columns: 1fr; } + .run-panel { grid-template-columns: minmax(0, 1fr) auto; grid-template-rows: auto auto; column-gap: 18px; } + .run-metrics { grid-column: 1 / -1; grid-row: 2; margin: 14px 0 0; grid-template-columns: repeat(4, minmax(0, 1fr)); } + .run-actions { align-self: center; } + .run-actions .btn { min-width: 120px; } + .control-toolbar { grid-template-columns: 1fr; } + .frequency-control { grid-template-columns: 150px minmax(0, 1fr); } + .toolbar-actions { justify-content: flex-start; } + .channel-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .bottom-grid { grid-template-columns: 1fr; } + .section-heading { align-items: flex-start; } + .section-heading p { text-align: left; max-width: 520px; } +} + +@media (max-width: 760px) { + .topbar { position: static; align-items: flex-start; gap: 12px; flex-wrap: wrap; } + .connection-strip { width: 100%; justify-content: space-between; } + .workspace { padding: 14px 12px 28px; } + .stream-panel, .run-panel, .phase-panel, .console-panel { padding: 16px; } + .panel-header { align-items: flex-start; } + .identity-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .profile-actions { align-items: flex-start; flex-direction: column; } + .file-state { max-width: 100%; white-space: normal; } + .run-panel { display: block; } + .run-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .run-actions { margin-top: 14px; } + .frequency-control { grid-template-columns: 1fr; } + .channel-grid { grid-template-columns: 1fr; } + .section-heading { display: block; } + .section-heading p { margin-top: 8px; } + .channel-fields { grid-template-columns: 1fr 1fr; } +} diff --git a/apps/smv_injector_gui/run.cmd b/apps/smv_injector_gui/run.cmd new file mode 100644 index 000000000..52c4d5354 --- /dev/null +++ b/apps/smv_injector_gui/run.cmd @@ -0,0 +1,260 @@ +@echo off +setlocal EnableExtensions +set "PORT=8765" +set "BUILD_ONLY=0" +if /I "%~1"=="--build-only" set "BUILD_ONLY=1" + +set "APP_DIR=%~dp0" +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" + +where python >nul 2>nul +if errorlevel 1 ( + echo Python was not found on PATH. + echo Run this launcher from the ESP-IDF shell or another shell with Python available. + pause + exit /b 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 or another shell with CMake available. + pause + exit /b 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 or another shell with Ninja available. + 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 +) + +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 + +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% +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 +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 +) + +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. +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 control-plane server. +echo. + +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 any installed native Windows compiler. +set "CC=" +set "CXX=" +set "AR=" +set "AS=" +set "LD=" +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 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 -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 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 +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 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. +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 + +:build_failed +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 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; +} diff --git a/apps/smv_injector_gui/styles.css b/apps/smv_injector_gui/styles.css new file mode 100644 index 000000000..9b0cd2e37 --- /dev/null +++ b/apps/smv_injector_gui/styles.css @@ -0,0 +1,212 @@ +:root { + --bg: #071018; + --bg-soft: #0b1621; + --panel: rgba(14, 27, 39, 0.92); + --panel-2: rgba(19, 35, 49, 0.88); + --line: rgba(131, 161, 183, 0.16); + --line-strong: rgba(131, 161, 183, 0.28); + --text: #eef6fb; + --muted: #8fa6b7; + --cyan: #4fc7ff; + --blue: #5b8cff; + --green: #3ddc97; + --amber: #f2bb4c; + --red: #ff6b76; + --shadow: 0 24px 60px rgba(0, 0, 0, 0.24); + --radius: 18px; +} + +* { box-sizing: border-box; } +html { min-width: 1120px; background: var(--bg); } +body { + margin: 0; + min-height: 100vh; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: var(--text); + background: + radial-gradient(circle at 10% 0%, rgba(79, 199, 255, 0.08), transparent 28rem), + radial-gradient(circle at 92% 8%, rgba(91, 140, 255, 0.08), transparent 30rem), + linear-gradient(180deg, #09131c 0%, #061018 100%); +} + +button, input { font: inherit; } +button { color: inherit; } + +.app-shell { min-height: 100vh; } +.topbar { + min-height: 84px; + padding: 18px 30px; + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; + z-index: 20; + border-bottom: 1px solid var(--line); + background: rgba(7, 16, 24, 0.88); + backdrop-filter: blur(18px); +} +.brand, .connection-strip, .panel-header, .run-state, .run-actions, +.toolbar-actions, .switch-row, .section-heading { display: flex; align-items: center; } +.brand { gap: 14px; } +.brand-mark { + width: 42px; height: 42px; border-radius: 12px; position: relative; + background: linear-gradient(145deg, rgba(79,199,255,.16), rgba(91,140,255,.10)); + border: 1px solid rgba(79,199,255,.28); + box-shadow: inset 0 0 24px rgba(79,199,255,.08); +} +.brand-mark span { + position: absolute; left: 10px; right: 10px; height: 2px; border-radius: 999px; + background: linear-gradient(90deg, transparent, var(--cyan), transparent); +} +.brand-mark span:nth-child(1){ top: 12px; transform: rotate(-24deg); } +.brand-mark span:nth-child(2){ top: 20px; } +.brand-mark span:nth-child(3){ top: 28px; transform: rotate(24deg); } +.brand h1, .panel h2, .section-heading h2 { margin: 2px 0 0; font-size: 20px; line-height: 1.15; } +.eyebrow { color: var(--muted); text-transform: uppercase; letter-spacing: .12em; font-size: 10px; font-weight: 750; } +.connection-strip { gap: 12px; } +.status-pill, .badge { + display: inline-flex; align-items: center; gap: 8px; height: 34px; padding: 0 12px; + border-radius: 999px; border: 1px solid var(--line); background: rgba(255,255,255,.025); + color: var(--muted); font-size: 12px; font-weight: 650; +} +.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #687985; box-shadow: 0 0 0 4px rgba(104,121,133,.10); } +.status-pill[data-state="online"] { color: #c8f7e5; border-color: rgba(61,220,151,.25); background: rgba(61,220,151,.08); } +.status-pill[data-state="online"] .status-dot { background: var(--green); box-shadow: 0 0 0 4px rgba(61,220,151,.12); } + +.workspace { padding: 24px 30px 40px; max-width: 1720px; margin: 0 auto; } +.hero-grid { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(370px, .85fr); gap: 18px; } +.panel { + border: 1px solid var(--line); + border-radius: var(--radius); + background: linear-gradient(180deg, rgba(17,31,44,.94), rgba(11,23,33,.94)); + box-shadow: var(--shadow); +} +.stream-panel, .run-panel, .phase-panel, .console-panel { padding: 20px; } +.panel-header { justify-content: space-between; gap: 16px; } +.panel-header.compact { min-height: 48px; } +.badge.neutral { color: #c5d6e2; } + +.stream-identity { + margin-top: 18px; padding: 16px; border: 1px solid var(--line); border-radius: 14px; + background: rgba(4, 12, 18, .35); +} +.identity-main { display: flex; align-items: baseline; gap: 12px; padding-bottom: 14px; border-bottom: 1px solid var(--line); } +.identity-label, .identity-grid span, .run-metrics span { color: var(--muted); font-size: 11px; } +.identity-main strong { font-size: 22px; letter-spacing: .02em; } +.identity-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; padding-top: 14px; } +.identity-grid div, .run-metrics div { display: grid; gap: 5px; } +.identity-grid strong, .run-metrics strong { font-size: 13px; font-weight: 700; } +.profile-actions { display: flex; align-items: center; gap: 14px; margin-top: 16px; } +.file-button { + cursor: pointer; min-height: 38px; display: inline-flex; align-items: center; padding: 0 13px; + border-radius: 10px; border: 1px solid rgba(79,199,255,.25); background: rgba(79,199,255,.08); + color: #cfefff; font-size: 12px; font-weight: 700; +} +.file-button input { display:none; } +.file-state { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.microcopy { margin: 12px 0 0; color: #7890a2; font-size: 11px; line-height: 1.55; } + +.run-panel { display: grid; grid-template-rows: auto 1fr auto; } +.run-state { gap: 12px; } +.run-icon { width: 12px; height: 12px; border-radius: 50%; background: #7f919e; box-shadow: 0 0 0 6px rgba(127,145,158,.08); } +.run-state[data-state="running"] .run-icon { background: var(--green); box-shadow: 0 0 0 6px rgba(61,220,151,.12), 0 0 24px rgba(61,220,151,.18); } +.run-state strong { display:block; margin-top: 3px; font-size: 28px; letter-spacing: .04em; } +.run-state[data-state="running"] strong { color: #bdf4dd; } +.run-metrics { align-self:center; display:grid; grid-template-columns: 1fr 1fr; gap: 14px; margin: 24px 0; } +.run-metrics div { min-height: 62px; padding: 12px; border:1px solid var(--line); border-radius: 12px; background: rgba(4,12,18,.28); } +.run-metrics strong { font-size: 18px; } +.run-actions { gap: 10px; } +.run-actions .btn { flex: 1; height: 44px; } + +.btn { + border: 1px solid transparent; border-radius: 10px; min-height: 38px; padding: 0 14px; + cursor: pointer; font-size: 12px; font-weight: 750; transition: transform .12s ease, opacity .12s ease, border-color .12s ease, background .12s ease; +} +.btn:hover:not(:disabled){ transform: translateY(-1px); } +.btn:active:not(:disabled){ transform: translateY(0); } +.btn:disabled{ opacity:.38; cursor:not-allowed; } +.btn.primary { background: linear-gradient(135deg, #237fb7, #315ed1); border-color: rgba(91,140,255,.34); } +.btn.secondary { background: rgba(255,255,255,.04); border-color: var(--line-strong); color: #d5e2eb; } +.btn.start { background: linear-gradient(135deg, #1c9e67, #157a66); border-color: rgba(61,220,151,.28); } +.btn.stop { background: rgba(255,107,118,.08); color: #ffc4c9; border-color: rgba(255,107,118,.26); } +.btn.ghost { background: transparent; border-color: var(--line); color: var(--muted); } +.btn.small { min-height:30px; padding:0 10px; } + +.control-toolbar { + margin-top: 18px; padding: 16px 18px; display:grid; grid-template-columns: minmax(440px,1fr) auto; gap: 22px; align-items:center; +} +.frequency-control { display:grid; grid-template-columns: 170px 1fr; gap:18px; align-items:center; } +.frequency-value { display:flex; align-items:baseline; gap:7px; margin-top:2px; } +.frequency-value input { + width:105px; border:0; outline:0; background:transparent; color:var(--text); font-size:24px; font-weight:760; padding:0; +} +.frequency-value span { color:var(--muted); font-size:12px; } +input[type="range"] { width:100%; accent-color: var(--cyan); cursor:pointer; } +.toolbar-actions { justify-content:flex-end; gap:9px; flex-wrap:wrap; } +.switch-row { gap:8px; color:#c9d8e2; font-size:12px; margin-right:4px; cursor:pointer; } +.switch-row input { display:none; } +.switch { width:36px; height:20px; border-radius:999px; background:#263845; border:1px solid var(--line-strong); position:relative; transition:.15s; } +.switch::after { content:""; position:absolute; top:3px; left:3px; width:12px; height:12px; border-radius:50%; background:#8193a0; transition:.15s; } +.switch-row input:checked + .switch { background:rgba(61,220,151,.20); border-color:rgba(61,220,151,.35); } +.switch-row input:checked + .switch::after { left:19px; background:var(--green); } + +.channels-section { margin-top: 26px; } +.section-heading { justify-content:space-between; gap:20px; margin-bottom:14px; } +.section-heading p { max-width:650px; margin:0; color:var(--muted); font-size:12px; line-height:1.55; text-align:right; } +.channel-grid { display:grid; grid-template-columns: repeat(4, minmax(240px,1fr)); gap:14px; } +.channel-card { + border:1px solid var(--line); border-radius:16px; padding:15px; + background:linear-gradient(180deg,rgba(17,31,44,.88),rgba(9,20,29,.88)); + box-shadow:0 14px 36px rgba(0,0,0,.14); transition:border-color .15s, transform .15s, opacity .15s; +} +.channel-card:hover { border-color:rgba(79,199,255,.22); transform:translateY(-1px); } +.channel-card[data-enabled="false"] { opacity:.55; } +.channel-head { display:flex; align-items:center; justify-content:space-between; gap:10px; } +.channel-title { display:flex; align-items:center; gap:10px; } +.phase-dot { width:10px; height:10px; border-radius:50%; background:var(--phase-color); box-shadow:0 0 0 5px color-mix(in srgb, var(--phase-color) 14%, transparent); } +.channel-title strong { font-size:17px; } +.channel-title span { display:block; margin-top:2px; color:var(--muted); font-size:10px; text-transform:uppercase; letter-spacing:.1em; } +.enable-toggle { position:relative; width:34px; height:20px; } +.enable-toggle input { display:none; } +.enable-toggle span { position:absolute; inset:0; border-radius:999px; background:#243642; border:1px solid var(--line-strong); cursor:pointer; } +.enable-toggle span::after { content:""; position:absolute; top:3px; left:3px; width:12px; height:12px; border-radius:50%; background:#748895; transition:.15s; } +.enable-toggle input:checked + span { background:rgba(61,220,151,.16); border-color:rgba(61,220,151,.28); } +.enable-toggle input:checked + span::after { left:17px; background:var(--green); } +.channel-fields { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:15px; } +.field { display:grid; gap:6px; } +.field label { color:var(--muted); font-size:10px; text-transform:uppercase; letter-spacing:.1em; } +.input-unit { position:relative; } +.input-unit input { + width:100%; height:42px; border-radius:10px; border:1px solid var(--line); outline:0; + background:rgba(3,10,15,.45); color:var(--text); padding:0 44px 0 11px; font-size:15px; font-weight:680; +} +.input-unit input:focus { border-color:rgba(79,199,255,.45); box-shadow:0 0 0 3px rgba(79,199,255,.07); } +.input-unit span { position:absolute; right:10px; top:50%; transform:translateY(-50%); color:#71899a; font-size:10px; } +.phase-slider { margin-top:10px; } +.channel-footer { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-top:12px; } +.wire-preview { color:#6f8798; font-size:10px; } +.channel-apply { min-height:32px; padding:0 10px; } + +.bottom-grid { margin-top:18px; display:grid; grid-template-columns:1.05fr .95fr; gap:18px; } +#phasorCanvas { width:100%; height:300px; display:block; margin-top:8px; border-radius:12px; background:rgba(3,10,15,.34); border:1px solid var(--line); } +#runtimeLog { + height:300px; overflow:auto; margin:8px 0 0; padding:14px; border:1px solid var(--line); border-radius:12px; + background:#050c12; color:#9ec3d6; font:11px/1.55 "Cascadia Mono", "SFMono-Regular", Consolas, monospace; white-space:pre-wrap; word-break:break-word; +} + +.toast { + position:fixed; right:24px; bottom:24px; z-index:40; min-width:240px; max-width:420px; + padding:12px 14px; border-radius:12px; border:1px solid rgba(79,199,255,.25); + background:rgba(12,27,39,.96); box-shadow:var(--shadow); color:#dff4ff; font-size:12px; + opacity:0; transform:translateY(10px); pointer-events:none; transition:.18s; +} +.toast.show { opacity:1; transform:translateY(0); } +.toast.error { border-color:rgba(255,107,118,.34); color:#ffd0d4; } + +@media (max-width: 1320px) { + .workspace { padding-left:20px; padding-right:20px; } + .channel-grid { grid-template-columns:repeat(2,1fr); } + .control-toolbar { grid-template-columns:1fr; } + .toolbar-actions { justify-content:flex-start; } +} diff --git a/docs/LIVE_INJECTION_STATE_MACHINE.md b/docs/LIVE_INJECTION_STATE_MACHINE.md new file mode 100644 index 000000000..063caeedd --- /dev/null +++ b/docs/LIVE_INJECTION_STATE_MACHINE.md @@ -0,0 +1,24 @@ +# Live injection state machine + +The deterministic publisher separates profile lifecycle from live signal lifecycle. + +```text +NO_PROFILE + | + v +PROFILE_VALIDATED + | + v +ARMED + | + +---- START ----> RUNNING + | | + | +---- live signal generation N -> N+1 + | +---- live signal generation N+1 -> N+2 + | | + | +---- STOP ----> STOPPED + | | + +--------------------------------------+ +``` + +Profile identity/layout changes are rejected while RUNNING. Live signal-state changes are allowed and committed coherently at a sample boundary. A development serial transport is currently used to prove this behavior; it will later be replaced by the versioned host/device control protocol without changing the realtime state semantics. diff --git a/docs/LIVE_INJECTION_WORKFLOW.md b/docs/LIVE_INJECTION_WORKFLOW.md new file mode 100644 index 000000000..300a16637 --- /dev/null +++ b/docs/LIVE_INJECTION_WORKFLOW.md @@ -0,0 +1,129 @@ +# Live Sampled Values Injection Workflow + +ARStack61850 targets an instrument workflow in which engineering intent is imported once, validated into a deterministic runtime profile, and signal values can then be changed while the publisher is running without rebuilding firmware or changing the canonical stream identity. + +## User workflow + +```text +Import SCL / CID / SCD / IID + | + v +Select and validate an SV stream + | + v +Resolve Class-A publisher profile + | + v +Configure initial signal values + | + v +Deploy profile to dedicated hardware + | + v +START + | + +---- live value / phase / frequency changes + | + +---- quality/state changes + | + +---- scenario transitions + | + v +STOP +``` + +The desktop/host side is the engineering and control plane. The ESP32-P4 is the deterministic realtime execution plane. + +## Separation of immutable and mutable state + +A running stream has two categories of state. + +### Immutable while armed/running + +These values define the stream/profile and are not edited in the per-sample hot path: + +- destination MAC; +- APPID; +- VLAN / PCP; +- svID and DataSet field-presence policy; +- confRev; +- nofASDU; +- sample-rate / sample-mode policy; +- validated sample-counter policy; +- ordered wire layout and payload widths; +- optional ASDU field policy. + +Changing an immutable property requires STOP -> validate/rebuild template -> ARM/START. This prevents half-updated frames. + +### Mutable while running + +These values are intentionally live: + +- per-channel magnitude or raw wire-count target; +- per-channel phase; +- signal frequency where supported by the active signal model; +- quality values; +- scenario/fault state; +- waveform source selector and transition state. + +A mutable update must become visible atomically at a sample boundary. The realtime task never parses text/XML, allocates memory, takes a host-facing mutex, or rebuilds BER structures. + +## Runtime state machine + +```text +EMPTY + -> PROFILE_VALIDATED + -> ARMED + -> RUNNING + -> STOPPED + +RUNNING --live signal update--> RUNNING +RUNNING --profile change------> reject; STOP required +``` + +`START` begins deterministic publication from a validated/armed profile. `STOP` stops canonical publication without destroying the profile. Live signal updates are accepted during RUNNING and committed as one coherent generation. + +## Coherent live update model + +The host/control task writes a complete next signal state into an inactive bank and then publishes a generation/index atomically. The realtime publisher copies one coherent generation at the start of a sample and uses only that snapshot for the whole payload. + +```text +control task realtime publisher +------------ ------------------ +copy active state +edit inactive bank +validate values +publish generation ----atomic----> copy coherent generation + synthesize one sample + patch prebuilt frame + transmit +``` + +This prevents mixed-channel updates such as Ia from a new command while Ib/Ic still belong to an older command. + +## Signal synthesis + +The first live engine uses a fixed-point phase accumulator plus a precomputed sine lookup table. Floating-point initialization is outside the realtime sample path; the sample hot path uses bounded integer work. + +A continuous phase accumulator allows magnitude, phase and frequency changes without restarting `smpCnt`. Frequency changes alter the next phase increment while preserving phase continuity. If realtime notifications are missed, both sample-count state and signal phase advance by the missed-slot count rather than replaying stale samples. + +The first bench control surface uses the serial console for direct hardware testing. It is a development transport only. The product control plane will use a versioned bounded command protocol and the same live-state semantics. + +## Safety and interoperability + +- canonical profile identity is never changed by a live signal-value command; +- profile changes require explicit stop/re-arm; +- `smpSynch` remains evidence-driven and is never promoted by a user value edit; +- SCL/profile scaling and semantic mapping stay host/profile responsibilities; raw wire-count control remains available for exact interoperability testing; +- unsupported channel semantics are rejected rather than guessed; +- diagnostic streams remain separate from canonical output. + +## Acceptance path + +1. Load a vendor-neutral SCL regression profile and resolve it to Class A. +2. Configure initial 4I+4V values. +3. Start the ESP32-P4 publisher and verify canonical identity/layout. +4. Change Ia/Uc/phase/frequency while RUNNING. +5. Verify the next coherent generation is visible on wire without sequence reset, malformed payload, or mixed-generation channels. +6. Verify STOP suppresses canonical TX and START resumes according to the runtime policy. +7. Repeat for 4000 and 4800 events/s profile families once compiled-device profiles are integrated. 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 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/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 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 diff --git a/embedded/esp32p4_smv_injector/main/CMakeLists.txt b/embedded/esp32p4_smv_injector/main/CMakeLists.txt index 1bd39c6ee..bd43d1526 100644 --- a/embedded/esp32p4_smv_injector/main/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/main/CMakeLists.txt @@ -2,6 +2,9 @@ idf_component_register( SRCS "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) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index a65bfa7b6..9fae74595 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -4,10 +4,12 @@ #include "ariec61850/sampled_values/asdu.hpp" #include "ariec61850/sampled_values/frame.hpp" #include "ariec61850/sampled_values/frame_codec.hpp" +#include "ariec61850/sampled_values/live_signal_state.hpp" #include "driver/gptimer.h" #include "ethernet_port.h" -#include "esp_attr.h" +#include "live_control.hpp" +#include "runtime_profile.hpp" #include "esp_err.h" #include "esp_eth.h" #include "esp_event.h" @@ -20,7 +22,6 @@ #include #include -#include #include #include #include @@ -30,38 +31,33 @@ 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; using ar::iec61850::sampled_values::SampledValueAsdu; using ar::iec61850::sampled_values::SampledValuesFrame; using ar::iec61850::sampled_values::SampledValuesFrameCodec; using ar::iec61850::sampled_values::SampledValuesPdu; +using ar::iec61850::sampled_values::SvFixedPointSineEngine; +using ar::iec61850::sampled_values::SvLiveSignalState; -constexpr char kTag[] = "ar_smv_p2"; +constexpr char kTag[] = "ar_smv_p3"; constexpr EventBits_t kLinkUpBit = BIT0; - -// Canonical stream identity is intentionally aligned with -// samples/scl/01_SV_Stream_4I+4V_(9-2LE).scd stream #1. -constexpr std::uint16_t kCanonicalAppId = 0x4000U; -constexpr char kCanonicalSvId[] = "MU01_SV1"; -constexpr std::array kSvMulticastMac{ - 0x01U, 0x0CU, 0xCDU, 0x04U, 0x00U, 0x01U}; - -// The broadcast path exists only because the currently tested host capture -// path does not reliably expose the standards-facing multicast/VLAN stream. -// Give it a separate identity so analyzers never merge canonical and mirror -// samples into one sequence. 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::uint32_t kSamplesPerCycle = 80U; -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 double kPi = 3.14159265358979323846; +constexpr std::uint32_t kTimerResolutionHz = 1000000U; EventGroupHandle_t g_link_events = nullptr; portMUX_TYPE g_stats_mux = portMUX_INITIALIZER_UNLOCKED; @@ -77,6 +73,7 @@ struct TimingStats final { std::int32_t lateness_min_us{std::numeric_limits::max()}; std::int32_t lateness_max_us{std::numeric_limits::min()}; std::uint16_t last_sample_count{}; + std::uint64_t last_signal_generation{}; }; TimingStats g_interval_stats{}; @@ -87,8 +84,39 @@ struct PacketTemplate final { std::size_t sample_payload_offset{std::numeric_limits::max()}; }; -using WaveformRow = std::array; -using WaveformTable = std::array; +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) { @@ -127,53 +155,44 @@ void write_i32_be(std::uint8_t* destination, const std::int32_t value) noexcept write_u32_be(destination, static_cast(value)); } -std::vector make_9_2le_shape_payload() { - // Dataset order from the SCL: Ia, Ib, Ic, In, Ua, Ub, Uc, Un. - // Each value is INT32 followed by UINT32 Quality. +std::vector make_4i4v_payload() { 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; - - // The supplied SCL has SmvOpts dataSet=false, so the canonical ASDU does - // not carry the dataset-reference field. - 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. The generic BER writer is minimal-width, while - // the realtime hot path must patch smpCnt/confRev in place without changing - // packet length. + // 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(); - asdu.sample_synchronization = 0U; // truthful: no measured PTP/1PPS lock. + // No disciplined PTP clock exists yet, so never claim synchronization. + asdu.sample_synchronization = 0U; - if (diagnostic_mirror) { - // Raw process-bus reconstruction needs these fields when SCL is not - // bound. Keep them on the diagnostic stream only; canonical output - // remains aligned with the supplied SCL SmvOpts sampleRate=false. - 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_9_2le_shape_payload(); + 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)}}; @@ -196,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}); @@ -215,97 +234,52 @@ 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; } -WaveformTable make_waveform_table() { - WaveformTable table{}; - - // 9-2LE-shaped integer scaling retained on the publisher side: - // current 1 mA/count, voltage 10 mV/count. - const double current_peak_counts = - static_cast(CONFIG_AR_SMV_CURRENT_RMS_MA) * std::sqrt(2.0); - const double voltage_peak_counts = - (static_cast(CONFIG_AR_SMV_VOLTAGE_RMS_MV) * std::sqrt(2.0)) / 10.0; - - for (std::size_t sample = 0U; sample < table.size(); ++sample) { - const double angle_a = (2.0 * kPi * static_cast(sample)) / - static_cast(kSamplesPerCycle); - const double angle_b = angle_a - (2.0 * kPi / 3.0); - const double angle_c = angle_a + (2.0 * kPi / 3.0); - - const auto ia = static_cast( - std::lround(current_peak_counts * std::sin(angle_a))); - const auto ib = static_cast( - std::lround(current_peak_counts * std::sin(angle_b))); - const auto ic = static_cast( - std::lround(current_peak_counts * std::sin(angle_c))); - const auto in = static_cast(ia + ib + ic); - - const auto ua = static_cast( - std::lround(voltage_peak_counts * std::sin(angle_a))); - const auto ub = static_cast( - std::lround(voltage_peak_counts * std::sin(angle_b))); - const auto uc = static_cast( - std::lround(voltage_peak_counts * std::sin(angle_c))); - const auto un = static_cast(ua + ub + uc); - - table[sample] = {ia, ib, ic, in, ua, ub, uc, un}; - } - - return table; -} - void patch_packet( PacketTemplate& packet, const std::uint16_t sample_count, - const WaveformRow& values) noexcept { + 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) { - write_i32_be(packet.bytes.data() + packet.sample_payload_offset + channel * 8U, - values[channel]); + const auto base = packet.sample_payload_offset + channel * 8U; + write_i32_be(packet.bytes.data() + base, values[channel]); + write_u32_be(packet.bytes.data() + base + 4U, signal_state.channels[channel].quality); } } #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-P2-CAPTURE-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; @@ -314,49 +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) noexcept { - const auto bounded_lateness = static_cast(std::clamp( + const std::uint16_t sample_count, + const std::uint64_t signal_generation) noexcept { + 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)); @@ -364,139 +355,155 @@ 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 P2 TX ENABLED: isolated lab network only"); + ESP_LOGW(kTag, "LIVE SV TX ENABLED: isolated lab network only"); ESP_LOGI(kTag, "Waiting for Ethernet link..."); xEventGroupWaitBits(g_link_events, kLinkUpBit, pdFALSE, pdTRUE, portMAX_DELAY); 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 P2 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 P2 publisher: broadcast mirror template build failed"); - vTaskDelete(nullptr); - return; - } -#endif - - const auto waveform = make_waveform_table(); - -#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, - "P2 waveform: 50 Hz, 80 samples/cycle, %u fps, period=%lld us, canonical smpCnt wrap=%u", - static_cast(kSampleRateHz), - static_cast(kSamplePeriodUs), - static_cast(kSampleCountWrap)); - ESP_LOGI(kTag, - "Waveform RMS: I=%d mA/phase, U=%d mV phase-neutral; dataset order Ia Ib Ic In Ua Ub Uc Un", - CONFIG_AR_SMV_CURRENT_RMS_MA, - CONFIG_AR_SMV_VOLTAGE_RMS_MV); - ESP_LOGI(kTag, - "SCL canonical: svID=%s dst=01:0C:CD:04:00:01 APPID=0x%04X %s confRev=1 smpSynch=0", - kCanonicalSvId, - static_cast(kCanonicalAppId), -#if CONFIG_AR_SMV_CANONICAL_VLAN - "VLAN PCP4/VID0" -#else - "untagged" -#endif - ); -#if CONFIG_AR_SMV_BROADCAST_MIRROR - ESP_LOGW(kTag, - "LAB mirror: svID=%s APPID=0x%04X untagged broadcast; explicit smpRate=4000 smpMod=SmpPerSec; independent 16-bit smpCnt", - kMirrorSvId, - static_cast(kMirrorAppId)); -#endif - ESP_LOGI(kTag, - "Fixed templates: canonical len=%u smpCnt@%u seqData@%u", - static_cast(canonical.bytes.size()), - static_cast(canonical.sample_count_offset), - static_cast(canonical.sample_payload_offset)); - - vTaskDelay(pdMS_TO_TICKS(100)); - const gptimer_handle_t sample_clock = start_sample_clock(xTaskGetCurrentTaskHandle()); - (void)sample_clock; - ESP_LOGI(kTag, - "P2 sample clock: GPTimer 1 MHz, periodic alarm every %lld us; publisher blocks between alarms", - static_cast(kSamplePeriodUs)); + "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)); + PacketTemplate canonical; + PacketTemplate mirror; + RuntimePublisherProfile active_profile = initial_profile; + static SvFixedPointSineEngine signal_engine; + 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) { + 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(); + 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, + 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, active_profile.publisher_rate_hz); if ((xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U) { ++local.canonical_fail; @@ -504,55 +511,40 @@ void publisher_task(void* argument) { ++local.mirror_fail; #endif } else { - const auto& row = waveform[canonical_sample_count % kSamplesPerCycle]; - patch_packet(canonical, canonical_sample_count, row); - + 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); + 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); - + update_local_timing( + 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; - ESP_LOGW(kTag, "P2 realtime publisher is disabled in menuconfig"); + ESP_LOGW(kTag, "Realtime publisher is disabled in menuconfig"); vTaskDelete(nullptr); #endif } @@ -560,22 +552,17 @@ 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, - "P2 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 canonical_smpCnt=%u", + "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), static_cast(snapshot.samples), static_cast(snapshot.canonical_ok), @@ -586,7 +573,8 @@ void telemetry_task(void*) { static_cast(snapshot.lateness_min_us), static_cast(mean_lateness_us), static_cast(snapshot.lateness_max_us), - static_cast(snapshot.last_sample_count)); + static_cast(snapshot.last_sample_count), + static_cast(snapshot.last_signal_generation)); } } @@ -605,37 +593,34 @@ extern "C" void app_main(void) { esp_eth_handle_t eth_handle = ar_esp32p4_eth_init(); if (eth_handle == nullptr) { - ESP_LOGE(kTag, "ESP32-P4-ETH bring-up failed"); + 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 + 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", 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; } #else - ESP_LOGW(kTag, "Live/P2 transmission disabled; Ethernet bring-up only"); + ESP_LOGW(kTag, "Live transmission disabled; Ethernet bring-up only"); #endif } diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp new file mode 100644 index 000000000..683397519 --- /dev/null +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -0,0 +1,375 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "live_control.hpp" +#include "profile_control.hpp" + +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::esp32p4::smv { +namespace { + +using ar::iec61850::sampled_values::SvLiveSignalBank; +using ar::iec61850::sampled_values::SvLiveSignalState; +using ar::iec61850::sampled_values::live_channel_index; + +constexpr char kTag[] = "ar_smv_ctrl"; +constexpr std::array kChannelNames{ + "IA", "IB", "IC", "IN", "UA", "UB", "UC", "UN"}; +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 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))); + } +} + +bool is_safe_ascii_command_line(const char* line) noexcept { + if (line == nullptr) return false; + bool has_payload = false; + for (const auto* cursor = reinterpret_cast(line); + *cursor != 0U; ++cursor) { + const auto byte = *cursor; + if (byte == static_cast(' ') || + byte == static_cast('\t') || + byte == static_cast('\r') || + byte == static_cast('\n')) { + continue; + } + if (byte < 0x20U || byte > 0x7EU) return false; + has_payload = true; + } + return has_payload; +} + +bool has_extra_token(char** save) noexcept { + return strtok_r(nullptr, kTokenDelimiters.data(), save) != nullptr; +} + +bool parse_i32(const char* text, std::int32_t& value) noexcept { + 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; + 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; + 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; +} + +void print_help() noexcept { + ESP_LOGI(kTag, "Live control commands (bench serial transport):"); + ESP_LOGI(kTag, " START | STOP | SHOW | ZERO | HELP"); + ESP_LOGI(kTag, " FREQ "); + ESP_LOGI(kTag, " SET [quality]"); + ESP_LOGI(kTag, " ENABLE <0|1>"); + ESP_LOGI(kTag, " QUALITY "); + ESP_LOGI(kTag, "GUI profile deployment uses bounded PROFILE subcommands while STOPPED."); +} + +void print_state() noexcept { + const auto state = g_signal_bank.snapshot(); + ESP_LOGI(kTag, + "state=%s generation=%llu signal_frequency=%u mHz", + g_running.load(std::memory_order_acquire) ? "RUNNING" : "STOPPED", + static_cast(state.generation), + static_cast(state.frequency_millihz)); + for (std::size_t index = 0U; index < state.channels.size(); ++index) { + const auto& channel = state.channels[index]; + ESP_LOGI(kTag, + "%.*s rms_counts=%ld phase_mdeg=%ld quality=0x%08lX enabled=%u", + static_cast(kChannelNames[index].size()), + kChannelNames[index].data(), + static_cast(channel.rms_counts), + static_cast(channel.phase_millidegrees), + static_cast(channel.quality), + channel.enabled ? 1U : 0U); + } +} + +bool publish_state(const SvLiveSignalState& state) noexcept { + if (!g_signal_bank.publish(state)) { + ESP_LOGE(kTag, "Rejected live signal update: value validation failed"); + return false; + } + const auto active = g_signal_bank.snapshot(); + ESP_LOGI(kTag, "Live signal generation %llu committed", + static_cast(active.generation)); + return true; +} + +void handle_line(char* line) noexcept { + char* save{}; + char* command = strtok_r(line, kTokenDelimiters.data(), &save); + if (command == nullptr) return; + uppercase_ascii(command); + + if (std::strcmp(command, "START") == 0) { + if (has_extra_token(&save)) { + ESP_LOGE(kTag, "Usage: START"); + return; + } + g_start_request.store(true, std::memory_order_release); + g_running.store(true, std::memory_order_release); + wake_publisher(); + ESP_LOGI(kTag, "START accepted: profile armed, phase and smpCnt reset at first sample boundary"); + return; + } + if (std::strcmp(command, "STOP") == 0) { + if (has_extra_token(&save)) { + ESP_LOGE(kTag, "Usage: STOP"); + return; + } + g_running.store(false, std::memory_order_release); + wake_publisher(); + ESP_LOGI(kTag, "STOP accepted: SV transmission suppressed"); + return; + } + if (std::strcmp(command, "SHOW") == 0) { + if (has_extra_token(&save)) { + ESP_LOGE(kTag, "Usage: SHOW"); + return; + } + print_state(); + return; + } + if (std::strcmp(command, "HELP") == 0) { + if (has_extra_token(&save)) { + ESP_LOGE(kTag, "Usage: HELP"); + return; + } + 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; + static_cast(publish_state(state)); + return; + } + if (std::strcmp(command, "FREQ") == 0) { + char* value_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); + std::uint32_t frequency{}; + if (!parse_u32(value_text, frequency) || has_extra_token(&save)) { + ESP_LOGE(kTag, "Usage: FREQ "); + return; + } + auto state = g_signal_bank.snapshot(); + state.frequency_millihz = frequency; + static_cast(publish_state(state)); + return; + } + + if (std::strcmp(command, "SET") == 0 || + std::strcmp(command, "ENABLE") == 0 || + std::strcmp(command, "QUALITY") == 0) { + char* channel_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); + if (channel_text == nullptr) { + ESP_LOGE(kTag, "Channel name is required"); + return; + } + uppercase_ascii(channel_text); + const auto channel_index = live_channel_index(channel_text); + if (!channel_index.has_value()) { + ESP_LOGE(kTag, "Unknown channel '%s'", channel_text); + return; + } + + auto state = g_signal_bank.snapshot(); + auto& channel = state.channels[*channel_index]; + + if (std::strcmp(command, "SET") == 0) { + char* rms_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); + char* phase_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); + char* quality_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); + 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]"); + return; + } + std::uint32_t quality{}; + if (quality_text != nullptr && !parse_u32(quality_text, quality)) { + ESP_LOGE(kTag, "Invalid quality value '%s'", quality_text); + return; + } + if (has_extra_token(&save)) { + ESP_LOGE(kTag, "Usage: SET [quality]"); + return; + } + channel.rms_counts = rms; + channel.phase_millidegrees = phase; + if (quality_text != nullptr) channel.quality = quality; + static_cast(publish_state(state)); + return; + } + + if (std::strcmp(command, "ENABLE") == 0) { + char* enabled_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); + std::uint32_t enabled{}; + if (!parse_u32(enabled_text, enabled) || enabled > 1U || has_extra_token(&save)) { + ESP_LOGE(kTag, "Usage: ENABLE <0|1>"); + return; + } + channel.enabled = enabled != 0U; + static_cast(publish_state(state)); + return; + } + + char* quality_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); + std::uint32_t quality{}; + if (!parse_u32(quality_text, quality) || has_extra_token(&save)) { + ESP_LOGE(kTag, "Usage: QUALITY "); + return; + } + channel.quality = quality; + static_cast(publish_state(state)); + return; + } + + ESP_LOGW(kTag, "Unknown command '%s'; type HELP", command); +} + +} // namespace + +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; + initial.channels[3].rms_counts = 0; + 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); +} + +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(); +} + +bool live_tx_running() noexcept { + return g_running.load(std::memory_order_acquire); +} + +bool take_start_request() noexcept { + return g_start_request.exchange(false, std::memory_order_acq_rel); +} + +void live_control_task(void*) noexcept { + std::setvbuf(stdin, nullptr, _IONBF, 0); + print_help(); + print_state(); + ESP_LOGI(kTag, "Console ready: type a complete command, then press Enter."); + + std::array line{}; + std::size_t length = 0U; + bool discard_until_eol = false; + + while (true) { + const int input = std::fgetc(stdin); + if (input == EOF) { + std::clearerr(stdin); + vTaskDelay(pdMS_TO_TICKS(10)); + continue; + } + + const auto byte = static_cast(input); + if (byte == static_cast('\r') || + byte == static_cast('\n')) { + if (discard_until_eol) { + discard_until_eol = false; + length = 0U; + line.fill('\0'); + ESP_LOGW(kTag, "Discarded invalid/overlong bench command"); + continue; + } + if (length == 0U) continue; + line[length] = '\0'; + if (!is_safe_ascii_command_line(line.data())) { + ESP_LOGW(kTag, "Discarded invalid bench command"); + } else { + handle_line(line.data()); + } + length = 0U; + line.fill('\0'); + continue; + } + + if (discard_until_eol) continue; + if (byte == 0x08U || byte == 0x7FU) { + if (length > 0U) { + --length; + line[length] = '\0'; + } + continue; + } + + const bool accepted = + byte == static_cast(' ') || + byte == static_cast('\t') || + (byte >= 0x21U && byte <= 0x7EU); + if (!accepted || length + 1U >= line.size()) { + discard_until_eol = true; + continue; + } + line[length++] = static_cast(byte); + } +} + +} // namespace ar::esp32p4::smv diff --git a/embedded/esp32p4_smv_injector/main/live_control.hpp b/embedded/esp32p4_smv_injector/main/live_control.hpp new file mode 100644 index 000000000..d80370d02 --- /dev/null +++ b/embedded/esp32p4_smv_injector/main/live_control.hpp @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "ariec61850/sampled_values/live_signal_state.hpp" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include + +namespace ar::esp32p4::smv { + +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; +void live_control_force_stop() noexcept; + +[[nodiscard]] ar::iec61850::sampled_values::SvLiveSignalState +live_signal_snapshot() noexcept; + +[[nodiscard]] bool live_tx_running() noexcept; +[[nodiscard]] bool take_start_request() noexcept; + +void live_control_task(void*) noexcept; + +} // namespace ar::esp32p4::smv diff --git a/embedded/esp32p4_smv_injector/main/live_control_commands.md b/embedded/esp32p4_smv_injector/main/live_control_commands.md new file mode 100644 index 000000000..4910fa2ce --- /dev/null +++ b/embedded/esp32p4_smv_injector/main/live_control_commands.md @@ -0,0 +1,40 @@ +# Bench live-control commands + +This serial command surface exists only to validate the realtime live-update semantics before the versioned host/device control protocol and desktop UI are added. + +```text +HELP +SHOW +START +STOP +ZERO +FREQ +SET [quality] +ENABLE <0|1> +QUALITY +``` + +Examples: + +```text +SET IA 1000 0 +SET IB 1000 -120000 +SET IC 1000 120000 +SET UA 5774 0 +SET UB 5774 -120000 +SET UC 5774 120000 +FREQ 50000 +START + +# while RUNNING +SET IA 5000 0 +SET UC 10000 90000 +FREQ 49950 +QUALITY IA 0x00000000 + +STOP +``` + +`rms_wire_counts` is intentionally profile-neutral. Engineering-unit conversion belongs to the resolved host profile. For the current development stream, the existing bench mapping is 1 mA/count for current values and 10 mV/count for voltage values. + +Live updates are copied into a coherent generation and become visible to the realtime publisher at a sample boundary. They do not modify stream identity, reset `smpCnt`, rebuild BER, or claim synchronization. `START` requests a clean phase/sample-counter restart; profile identity changes require STOP/re-arm in the future compiled-profile runtime. 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 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 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 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 diff --git a/include/ariec61850/sampled_values/live_signal_state.hpp b/include/ariec61850/sampled_values/live_signal_state.hpp new file mode 100644 index 000000000..37ca82617 --- /dev/null +++ b/include/ariec61850/sampled_values/live_signal_state.hpp @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +constexpr std::size_t kLiveSignalChannelCount = 8U; +constexpr std::size_t kLiveSineLutSize = 4096U; + +struct SvLiveChannelState final { + // RMS magnitude in raw wire counts. Engineering-unit conversion belongs + // to the host/profile layer so the realtime engine remains profile-neutral. + std::int32_t rms_counts{}; + std::int32_t phase_millidegrees{}; + std::uint32_t quality{}; + bool enabled{true}; + + friend bool operator==(const SvLiveChannelState&, const SvLiveChannelState&) = default; +}; + +struct SvLiveSignalState final { + // Fundamental signal frequency in millihertz. This is deliberately + // independent of the SV publisher event rate. + std::uint32_t frequency_millihz{50000U}; + std::array channels{}; + std::uint64_t generation{}; + + friend bool operator==(const SvLiveSignalState&, const SvLiveSignalState&) = default; +}; + +class SvLiveSignalBank final { +public: + SvLiveSignalBank() noexcept { + banks_[0] = default_state(); + banks_[1] = banks_[0]; + banks_[2] = banks_[0]; + } + + // One control-plane writer and any number of serialized realtime snapshots + // are expected. The reader advertises the bank it is copying before the + // copy begins. With three banks, the writer can always select a bank that + // is neither active nor being read, so no non-atomic object is read and + // written concurrently. + [[nodiscard]] SvLiveSignalState snapshot() const noexcept { + while (true) { + const auto index = active_index_.load(std::memory_order_acquire); + reader_index_.store(index, std::memory_order_release); + + if (active_index_.load(std::memory_order_acquire) != index) { + reader_index_.store(kNoReader, std::memory_order_release); + continue; + } + + const auto copy = banks_[index]; + reader_index_.store(kNoReader, std::memory_order_release); + return copy; + } + } + + [[nodiscard]] std::uint32_t active_index() const noexcept { + return active_index_.load(std::memory_order_acquire); + } + + [[nodiscard]] bool publish(const SvLiveSignalState& requested) noexcept { + if (!validate(requested)) { + return false; + } + + const auto current = active_index_.load(std::memory_order_acquire); + const auto reader = reader_index_.load(std::memory_order_acquire); + + std::uint32_t next = kNoReader; + for (std::uint32_t candidate = 0U; candidate < banks_.size(); ++candidate) { + if (candidate != current && candidate != reader) { + next = candidate; + break; + } + } + if (next == kNoReader) { + return false; + } + + banks_[next] = requested; + banks_[next].generation = banks_[current].generation + 1U; + active_index_.store(next, std::memory_order_release); + return true; + } + + [[nodiscard]] static bool validate(const SvLiveSignalState& state) noexcept { + if (state.frequency_millihz < 1000U || state.frequency_millihz > 1000000U) { + return false; + } + for (const auto& channel : state.channels) { + if (channel.rms_counts == std::numeric_limits::min()) { + return false; + } + if (channel.phase_millidegrees < -360000000 || + channel.phase_millidegrees > 360000000) { + return false; + } + } + return true; + } + + [[nodiscard]] static SvLiveSignalState default_state() noexcept { + SvLiveSignalState state; + state.channels[0] = {1000, 0, 0U, true}; + state.channels[1] = {1000, -120000, 0U, true}; + state.channels[2] = {1000, 120000, 0U, true}; + state.channels[3] = {0, 0, 0U, true}; + state.channels[4] = {5774, 0, 0U, true}; + state.channels[5] = {5774, -120000, 0U, true}; + state.channels[6] = {5774, 120000, 0U, true}; + state.channels[7] = {0, 0, 0U, true}; + return state; + } + +private: + static constexpr std::uint32_t kNoReader = 3U; + std::array banks_{}; + mutable std::atomic reader_index_{kNoReader}; + std::atomic active_index_{0U}; +}; + +class SvFixedPointSineEngine final { +public: + using SampleRow = std::array; + + SvFixedPointSineEngine() noexcept { + build_lut(); + } + + void reset_phase(const std::uint32_t phase = 0U) noexcept { + phase_accumulator_ = phase; + } + + void advance( + const SvLiveSignalState& state, + const std::uint32_t publisher_rate_hz, + const std::uint32_t slots) noexcept { + const std::uint64_t delta = + static_cast(phase_step_for(state.frequency_millihz, publisher_rate_hz)) * + static_cast(slots); + phase_accumulator_ += static_cast(delta); + } + + [[nodiscard]] std::uint32_t phase_accumulator() const noexcept { + return phase_accumulator_; + } + + [[nodiscard]] SampleRow next( + const SvLiveSignalState& state, + const std::uint32_t publisher_rate_hz) noexcept { + SampleRow row{}; + if (publisher_rate_hz == 0U) { + return row; + } + + const auto phase_step = phase_step_for(state.frequency_millihz, publisher_rate_hz); + const auto base_phase = phase_accumulator_; + + for (std::size_t index = 0U; index < row.size(); ++index) { + const auto& channel = state.channels[index]; + if (!channel.enabled || channel.rms_counts == 0) { + row[index] = 0; + continue; + } + + const auto offset = phase_from_millidegrees(channel.phase_millidegrees); + const std::uint32_t phase = base_phase + offset; + const auto lut_index = static_cast(phase >> (32U - 12U)); + const std::int64_t sine_q30 = sine_lut_[lut_index & (kLiveSineLutSize - 1U)]; + const std::int64_t peak_counts = rms_to_peak_counts(channel.rms_counts); + const std::int64_t value = (sine_q30 * peak_counts) >> 30U; + row[index] = clamp_i32(value); + } + + phase_accumulator_ += phase_step; + return row; + } + + [[nodiscard]] static std::uint32_t phase_step_for( + const std::uint32_t frequency_millihz, + const std::uint32_t publisher_rate_hz) noexcept { + if (publisher_rate_hz == 0U) { + return 0U; + } + constexpr std::uint64_t full_turn = (std::uint64_t{1} << 32U); + const std::uint64_t denominator = + static_cast(publisher_rate_hz) * 1000ULL; + const std::uint64_t numerator = + full_turn * static_cast(frequency_millihz); + return static_cast((numerator + denominator / 2ULL) / denominator); + } + + [[nodiscard]] static std::uint32_t phase_from_millidegrees( + const std::int32_t millidegrees) noexcept { + constexpr std::int64_t full_turn_md = 360000LL; + constexpr std::uint64_t full_turn = (std::uint64_t{1} << 32U); + std::int64_t normalized = static_cast(millidegrees) % full_turn_md; + if (normalized < 0) { + normalized += full_turn_md; + } + return static_cast( + (static_cast(normalized) * full_turn) / + static_cast(full_turn_md)); + } + +private: + std::array sine_lut_{}; + std::uint32_t phase_accumulator_{}; + + void build_lut() noexcept { + constexpr double pi = 3.141592653589793238462643383279502884; + constexpr double q30 = static_cast(std::uint64_t{1} << 30U); + for (std::size_t index = 0U; index < sine_lut_.size(); ++index) { + const double angle = 2.0 * pi * static_cast(index) / + static_cast(sine_lut_.size()); + sine_lut_[index] = static_cast(std::llround(std::sin(angle) * q30)); + } + } + + [[nodiscard]] static std::int64_t rms_to_peak_counts(const std::int32_t rms) noexcept { + constexpr std::int64_t sqrt2_q30 = 1518500250LL; + return (static_cast(rms) * sqrt2_q30) >> 30U; + } + + [[nodiscard]] static std::int32_t clamp_i32(const std::int64_t value) noexcept { + if (value > std::numeric_limits::max()) { + return std::numeric_limits::max(); + } + if (value < std::numeric_limits::min()) { + return std::numeric_limits::min(); + } + return static_cast(value); + } +}; + +[[nodiscard]] inline std::optional live_channel_index( + const std::string_view name) noexcept { + constexpr std::array names{ + "IA", "IB", "IC", "IN", "UA", "UB", "UC", "UN"}; + for (std::size_t index = 0U; index < names.size(); ++index) { + if (name == names[index]) { + return index; + } + } + return std::nullopt; +} + +} // namespace ar::iec61850::sampled_values 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_live_signal.cpp b/tests/test_live_signal.cpp new file mode 100644 index 000000000..0f353d30e --- /dev/null +++ b/tests/test_live_signal.cpp @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/sampled_values/live_signal_state.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +#define CHECK(condition) do { \ + if (!(condition)) { \ + throw std::runtime_error(std::string{"CHECK failed: "} + #condition + \ + " at " + __FILE__ + ":" + std::to_string(__LINE__)); \ + } \ +} while (false) + +void bank_commits_complete_generations() { + using namespace ar::iec61850::sampled_values; + + SvLiveSignalBank bank; + const auto before = bank.snapshot(); + CHECK(before.generation == 0U); + CHECK(before.channels[0].rms_counts == 1000); + CHECK(before.channels[1].phase_millidegrees == -120000); + + auto next = before; + next.frequency_millihz = 60000U; + next.channels[0].rms_counts = 2500; + next.channels[4].rms_counts = 12000; + next.channels[4].phase_millidegrees = 15000; + + CHECK(bank.publish(next)); + const auto after = bank.snapshot(); + CHECK(after.generation == 1U); + CHECK(after.frequency_millihz == 60000U); + CHECK(after.channels[0].rms_counts == 2500); + CHECK(after.channels[4].rms_counts == 12000); + CHECK(after.channels[4].phase_millidegrees == 15000); + + auto invalid = after; + invalid.frequency_millihz = 0U; + CHECK(!bank.publish(invalid)); + CHECK(bank.snapshot() == after); +} + +void fixed_point_engine_preserves_frequency_and_phase() { + using namespace ar::iec61850::sampled_values; + + SvFixedPointSineEngine engine; + auto state = SvLiveSignalBank::default_state(); + state.frequency_millihz = 50000U; + + const auto step = SvFixedPointSineEngine::phase_step_for(50000U, 4000U); + const auto accumulated = static_cast( + static_cast(step) * 80ULL); + CHECK(accumulated < 80U || accumulated > 0xFFFFFFB0U); + + std::int32_t max_a{}; + std::int32_t min_a{}; + for (std::size_t sample = 0U; sample < 80U; ++sample) { + const auto row = engine.next(state, 4000U); + max_a = std::max(max_a, row[0]); + min_a = std::min(min_a, row[0]); + } + CHECK(max_a > 1300); + CHECK(max_a < 1500); + CHECK(min_a < -1300); + CHECK(min_a > -1500); + + const auto phase_before = engine.phase_accumulator(); + state.channels[0].rms_counts = 2000; + static_cast(engine.next(state, 4000U)); + CHECK(engine.phase_accumulator() != phase_before); + + const auto phase_before_skip = engine.phase_accumulator(); + engine.advance(state, 4000U, 3U); + CHECK(engine.phase_accumulator() != phase_before_skip); + + CHECK(SvFixedPointSineEngine::phase_from_millidegrees(0) == 0U); + const auto phase_120 = SvFixedPointSineEngine::phase_from_millidegrees(120000); + const auto phase_minus_120 = SvFixedPointSineEngine::phase_from_millidegrees(-120000); + CHECK(phase_120 != 0U); + CHECK(phase_minus_120 != 0U); + CHECK(phase_120 != phase_minus_120); +} + +void independent_channels_change_without_stream_restart() { + using namespace ar::iec61850::sampled_values; + + SvLiveSignalBank bank; + SvFixedPointSineEngine engine; + + const auto first_generation = bank.snapshot().generation; + for (std::size_t sample = 0U; sample < 17U; ++sample) { + const auto active = bank.snapshot(); + static_cast(engine.next(active, 4000U)); + } + const auto phase_before_update = engine.phase_accumulator(); + + auto updated = bank.snapshot(); + updated.channels[0].rms_counts = 3500; + updated.channels[0].phase_millidegrees = 30000; + updated.channels[6].rms_counts = 9000; + CHECK(bank.publish(updated)); + + const auto active = bank.snapshot(); + CHECK(active.generation == first_generation + 1U); + const auto row = engine.next(active, 4000U); + CHECK(engine.phase_accumulator() != phase_before_update); + CHECK(row[0] != 0); + CHECK(row[6] != 0); +} + +} // namespace + +int main() { + try { + bank_commits_complete_generations(); + fixed_point_engine_preserves_frequency_and_phase(); + independent_channels_change_without_stream_restart(); + std::cout << "[PASS] live Sampled Values signal state\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "[FAIL] live Sampled Values signal state: " << error.what() << '\n'; + return 1; + } +} 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}, 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; + } +}