diff --git a/.github/workflows/control-interop-harness-ci.yml b/.github/workflows/control-interop-harness-ci.yml new file mode 100644 index 000000000..9623c7fed --- /dev/null +++ b/.github/workflows/control-interop-harness-ci.yml @@ -0,0 +1,92 @@ +name: Control Interop Harness CI + +on: + push: + branches: + - "agent/phase-4d-c5-control-interoperability" + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + control-interop: + name: Control Interop Harness / ${{ matrix.compiler }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + compiler: gcc + - os: ubuntu-latest + compiler: clang + - os: windows-latest + compiler: msvc + + steps: + - uses: actions/checkout@v4 + + - name: Select compiler + if: runner.os == 'Linux' + shell: bash + run: | + if [ "${{ matrix.compiler }}" = "clang" ]; then + echo "CC=clang" >> "$GITHUB_ENV" + echo "CXX=clang++" >> "$GITHUB_ENV" + else + echo "CC=gcc" >> "$GITHUB_ENV" + echo "CXX=g++" >> "$GITHUB_ENV" + fi + + - name: Configure C5 harness + shell: bash + run: cmake -S tools/control_interop_probe -B build-control-interop + + - name: Build C5 harness + shell: bash + run: cmake --build build-control-interop --config Release --parallel + + - name: Run offline safety self-test + shell: bash + run: ctest --test-dir build-control-interop -C Release --output-on-failure + + - name: Stage Linux harness and contract + if: runner.os == 'Linux' + shell: bash + run: | + mkdir -p evidence/control-interop + ./build-control-interop/ariec61850_control_interop_probe --self-test \ + | tee evidence/control-interop/self-test-${{ matrix.compiler }}.txt + ./build-control-interop/ariec61850_control_interop_probe --help \ + > evidence/control-interop/help-${{ matrix.compiler }}.txt + ./build-control-interop/ariec61850_control_inventory_probe --self-test \ + | tee evidence/control-interop/inventory-self-test-${{ matrix.compiler }}.txt + ./build-control-interop/ariec61850_control_inventory_probe --help \ + > evidence/control-interop/inventory-help-${{ matrix.compiler }}.txt + cp build-control-interop/ariec61850_control_interop_probe \ + evidence/control-interop/ariec61850_control_interop_probe-${{ matrix.compiler }} + cp build-control-interop/ariec61850_control_inventory_probe \ + evidence/control-interop/ariec61850_control_inventory_probe-${{ matrix.compiler }} + + - name: Stage Windows harness and contract + if: runner.os == 'Windows' + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path evidence/control-interop | Out-Null + $exe = 'build-control-interop/Release/ariec61850_control_interop_probe.exe' + $inventory = 'build-control-interop/Release/ariec61850_control_inventory_probe.exe' + & $exe --self-test | Tee-Object -FilePath evidence/control-interop/self-test-msvc.txt + & $exe --help | Out-File -Encoding utf8 evidence/control-interop/help-msvc.txt + & $inventory --self-test | Tee-Object -FilePath evidence/control-interop/inventory-self-test-msvc.txt + & $inventory --help | Out-File -Encoding utf8 evidence/control-interop/inventory-help-msvc.txt + Copy-Item $exe evidence/control-interop/ariec61850_control_interop_probe.exe + Copy-Item $inventory evidence/control-interop/ariec61850_control_inventory_probe.exe + + - name: Upload C5 harness evidence and binary + uses: actions/upload-artifact@v4 + with: + name: control-interop-harness-${{ matrix.compiler }} + path: evidence/control-interop + if-no-files-found: error diff --git a/MIGRATION_CHECKLIST.md b/MIGRATION_CHECKLIST.md index 872e7dd4d..505d586f4 100644 --- a/MIGRATION_CHECKLIST.md +++ b/MIGRATION_CHECKLIST.md @@ -22,8 +22,8 @@ behavioral oracle until laboratory interoperability is complete. - [x] Deterministic 8,000-frame / two-second SV host simulation at 4 kHz with independent exact-byte Python PCAP oracle on strict GCC/Clang embedded profiles. - [x] ESP32-P4 ESP-IDF v6.0.2 cross-compile/link acceptance for both the integration smoke app and real first-trial SV firmware. - [x] ESP32-P4 `FLASHABLE/READY` CI artifact gate: ESP-IDF manifest targets `esp32p4`, validates the `0x2000` bootloader / `0x8000` partition / `0x10000` application layout, retains BIN/ELF/map/flash metadata, and publishes verified SHA-256 checksums. -- [ ] Real IED or vendor-simulator GOOSE/SV capture accepted. -- [~] Real-time SV publisher timing-health validation: deterministic host/CI pacing implemented; physical ESP32-P4 evidence pending. +- [ ] Real IED or vendor-simulator GOOSE/SV capture accepted in this migration evidence set. +- [~] Real-time SV publisher timing-health validation: deterministic host/CI pacing implemented; physical process-bus work is maintained separately from the MMS-control branch. - [ ] Exception-free embedded codec build; the first ESP-IDF trial temporarily enables C++ exceptions for shared legacy validation/convenience APIs while the publisher hot path remains `noexcept`. ## Phase 2 — engineering file formats @@ -115,19 +115,19 @@ behavioral oracle until laboratory interoperability is complete. - [x] URCB reservation, RptEna enable/disable, optional GI, and touched-state cleanup. - [x] Persistent report polling and Phase 4A monitor ingestion. - [x] Cleanup-required evidence and retry path after association loss. -- [ ] BRCB EntryID resume, purge decisioning, and buffer-overflow recovery. +- [x] BRCB retained-history replay, EntryID resume/rewind, PurgeBuf, replay-gap/overflow recovery, association ownership, ResvTms/Owner lifecycle, and recovery image v2 with legacy v1 restore. ### Phase 4C — built-in TCP and live read-only discovery -- [~] Non-blocking Windows Winsock and POSIX TCP `MmsByteTransport` implementation. -- [~] IPv4/IPv6 resolution, TCP_NODELAY, keepalive, partial-send, and peer-close handling. -- [~] Deadline and cancellation-aware connect/send/receive readiness waits. -- [~] Bounded live GetNameList pagination for domains, variables, and DataSets. -- [~] Optional GetVariableAccessAttributes type probes. -- [~] Optional GetNamedVariableListAttributes DataSet directory reads. -- [~] Optional read-only RCB attribute inventory/state probes. -- [~] Read-only `ariec61850_live_discover` human/JSON CLI. -- [~] Discovery service allowlist regression proving no MMS Write request is emitted. +- [x] Non-blocking Windows Winsock and POSIX TCP `MmsByteTransport` implementation. +- [x] IPv4/IPv6 resolution, TCP_NODELAY, keepalive, partial-send, and peer-close handling. +- [x] Deadline and cancellation-aware connect/send/receive readiness waits. +- [x] Bounded live GetNameList pagination for domains, variables, and DataSets. +- [x] Optional GetVariableAccessAttributes type probes. +- [x] Optional GetNamedVariableListAttributes DataSet directory reads. +- [x] Optional read-only RCB attribute inventory/state probes. +- [x] Read-only `ariec61850_live_discover` human/JSON CLI. +- [x] Discovery service allowlist regression proving no MMS Write request is emitted. ### Phase 4C.1 — live-model parity and physical read-only interoperability @@ -152,20 +152,50 @@ behavioral oracle until laboratory interoperability is complete. - [x] Controlled OCR7SR12 timeout/recovery evidence accepted: healthy baseline, post-association response withholding, client request timeout observed, fresh direct recovery, and identical `934b555dff76a46f` structural fingerprint before/after recovery. - [x] Physical GetNameList pagination accepted on OCR7SR12: 9 queries, 4 paginated queries, 88 continuation requests; largest sequence 48 pages / 4,758 names / 47 continuations with final `moreFollows=false`. +### Phase 4D-R — BRCB operational semantics + +- [x] Retained bounded history with independent delivery cursor. +- [x] EntryID replay/resume and rewind-to-oldest semantics. +- [x] PurgeBuf and explicit replay-gap handling. +- [x] Association-aware reservation/ownership with ResvTms reconnect/expiry behavior. +- [x] MMS BRCB operational object exposure and association-context propagation. +- [x] TCP/reset/COTP-DR association-loss lifecycle bridge. +- [x] Recovery image v2 preserves retained replay history/cursor/gap and restores legacy v1. +- [~] Physical ESP32-P4 BRCB raw-partition geometry/latency/endurance/power-loss evidence remains a separate NVM gate. + +### Phase 4D-C — guarded IEC 61850 Control Model parity + +- [x] C1 association-aware, default-deny guarded-control safety state machine. +- [x] C1 Direct normal, SBO normal, Direct enhanced, and SBO enhanced lifecycle semantics. +- [x] C1 immutable selected sequence, ownership, timeout, Cancel, association-loss cleanup, and ctlNum 1..255 wrap. +- [x] C2 exact live-TypeSpecification `Oper`, `SBOw`, and `Cancel` structure builder. +- [x] C2 typed SPC/DPC/integer/unsigned/floating/step control values and conservative vendor-field rejection. +- [x] C2 exact origin, ctlNum, T, optional operTm, Test, and two-bit Check construction with golden MMS Data evidence. +- [x] C3 enhanced-security CommandTermination and LastApplError object correlation. +- [x] C3 standard ControlError and AddCause 0..27 mapping with raw unknown-code preservation. +- [x] C4 live ctlModel/GVAA/GetNameList/CF-timeout discovery over the production MMS association runtime. +- [x] C4 Direct/SBO/SBOw/Oper/Cancel orchestration, asynchronous application-error grace window, non-fatal enhanced termination timeout, and no automatic command retry. +- [x] C1-C4 integrated into the normal `ARIEC61850::core` build and GCC/Clang/MSVC CTest matrix. +- [x] C5 guarded live-interoperability harness implemented with read-only default, explicit arm token, typed values, status-before/after reads, deterministic control-Write counting, JSON evidence, Wireshark filter hint, and offline safety self-test. +- [x] C5 dedicated GCC/Clang/MSVC harness CI and build artifact workflow implemented. +- [~] Physical/simulator C5 acceptance for Direct normal, SBO normal, Direct enhanced, SBO enhanced, negative LastApplError/AddCause, and association-loss cases is pending retained laboratory evidence. + ### Later Phase 4 work -- [ ] Direct and SBO control state machines. -- [ ] Enhanced-security termination/error handling. - [ ] MMS file and fault-record transfer. +- [ ] Dynamic DataSet define/delete parity and full dynamic RCB lifecycle parity where still missing from the C# surface. ## Phase 5 — simulator and applications -- [ ] Deterministic IED simulation and read-only MMS server. -- [ ] CLI parity and Windows Npcap abstraction. -- [ ] Native Engineering Workbench, simulator, discovery, and SV publisher UI. +- [ ] Deterministic IED simulation and read-only MMS server integration parity. +- [ ] CLI/application parity and broader Windows Npcap abstraction where still required. +- [ ] Native Engineering Workbench, simulator, discovery, and SV publisher UI parity where product scope still requires it. ## Safety gate -SCL, COMTRADE, PCAP, and the Phase 4C/4C.1 live discovery surface are read-only. Active RCB -mutation, control, dynamic DataSet mutation, file transfer, Npcap transmission, and real-time SV -scheduling require separate explicit APIs, authorization, and physical-laboratory acceptance. +SCL, COMTRADE, PCAP, and the Phase 4C/4C.1 discovery surface remain read-only. Phase 4D-C +live control is an explicit, authorization-gated API and the C5 interoperability harness is +read-only by default; it refuses a control Write without the exact laboratory arm token. +Control, dynamic DataSet mutation, file transfer, Npcap transmission, and real-time process-bus +scheduling require their own explicit APIs and evidence gates. Hosted CI does not constitute +physical IED interoperability or IEC 61850 conformance certification. diff --git a/MIGRATION_PLAN.md b/MIGRATION_PLAN.md index 57872a991..bb97248ad 100644 --- a/MIGRATION_PLAN.md +++ b/MIGRATION_PLAN.md @@ -1,64 +1,120 @@ # ARIEC61850 C++ Migration Plan The migration follows deterministic protocol foundations first, then engineering formats, -association/runtime behavior, live read-only interoperability, and finally active services and -applications. The C# repository remains the behavioral oracle until same-target laboratory -comparison is accepted. +association/runtime behavior, live read-only interoperability, bounded active-service semantics, +and finally physical interoperability plus application parity. The C# repository remains the +behavioral oracle until same-target laboratory comparison is accepted. ## Completed foundation - Phase 0: C++20/CMake, strict warnings, BER, Ethernet/VLAN, PCAP. - Phase 1: MMS Data/UTC, GOOSE, Sampled Values, evidence and fuzzing. -- Phase 2: read-only SCL and COMTRADE. +- Phase 2: read-only SCL and COMTRADE foundations. - Phase 3: TPKT/COTP, Session, Presentation, ACSE, MMS Initiate and confirmed services. - Phase 4A: DataSet/RCB inventory, InformationReport decoding, continuity monitoring. - Phase 4B: transport-injected association and persistent report-subscription runtime. +- Phase 4C/4C.1: built-in Windows/Linux TCP, live read-only discovery/model projection, + C#↔C++ structural/type parity, repeated physical read-only interoperability, timeout/recovery, + RCB/control-block deep-read evidence, and large-model GetNameList pagination. -## Current delivery — Phase 4C and 4C.1 +## Completed software tranche — Phase 4D-R -### Phase 4C +BRCB operational semantics are implemented and regression-tested: -- built-in non-blocking Windows/Linux TCP transport; -- deadline/cancellation-aware connect, send, and receive; -- bounded live domain, variable, DataSet, type, and RCB discovery; -- strict read-only service allowlist. +- bounded retained report history with an independent delivery cursor; +- EntryID replay/resume and rewind-to-oldest; +- PurgeBuf and replay-gap/overflow handling; +- association-aware reservation/Owner/ResvTms lifecycle; +- association identity propagation through MMS writes; +- TCP/reset/COTP-DR association-loss cleanup; +- replay-capable recovery image v2 with legacy-v1 restore. -### Phase 4C.1 +Physical BRCB raw-partition geometry, latency, endurance budgeting and controlled power-loss +acceptance remain a separate ESP32-P4 NVM evidence gate. -- C#-compatible `live-ied-model-v1` hierarchy; -- IED/LD/LN/DO/DA references, FC, type, DataSet, and RCB evidence; -- conservative identity and CDC inference; -- deterministic manifest/fingerprint; -- C#↔C++ parity checker; -- repeated physical read-only interoperability runner and evidence format. +## Current delivery — Phase 4D-C5 guarded control interoperability -### Acceptance work still required +### C1 — safety state machine -- GCC/Clang/MSVC and Security/Evidence workflow run on PR #14; -- full branch CTest; -- reachable IED/vendor-simulator run; -- primary-vendor ten-cycle and timeout/reconnect evidence; -- same-target C# and C++ parity review; -- large-model pagination evidence when available. +- `ctlModel` 0..4 parity with the C# oracle; +- Direct normal, SBO normal, Direct enhanced and SBO enhanced lifecycle semantics; +- association-aware ownership and default-deny authorization; +- immutable selected sequence across ctlVal/origin/ctlNum/T/operTm/Test/Check; +- selection timeout, association-loss cleanup and explicit Cancel; +- ctlNum 1..255 wrap with zero excluded; +- no automatic command retry. -## Next implementation priority after Phase 4C.1 acceptance +### C2 — exact MMS control structures -### Phase 4D — guarded control foundation +- live TypeSpecification-driven `Oper`, `SBOw` and `Cancel` construction; +- conservative SPC/DPC/integer/unsigned/floating/step-position binding; +- exact origin, ctlNum, T, optional operTm, Test and two-bit Check fields; +- DPC network-bit-order parity and golden MMS Data vectors; +- unknown vendor-specific fields are rejected instead of guessed. -- Direct Operate and Select-Before-Operate state machines; -- normal and enhanced security; -- select, operate, cancel, ownership, timeout, and command termination; -- LastApplError and AddCause; -- default-deny authorization, operator confirmation, and full audit trail; -- no automatic command retry. +### C3 — enhanced-security completion and diagnostics + +- correlated CommandTermination handling; +- LastApplError decoding with standard ControlError and AddCause 0..27 mapping; +- raw unknown-code preservation; +- exact controlled-object correlation; +- ordinary ST/MX process reports cannot complete an enhanced command. + +### C4 — live guarded control session + +- live ctlModel Read and exact Oper/SBOw/Cancel GVAA discovery; +- domain variable discovery and CF sboTimeout/operTimeout reads; +- Direct Operate, SBO Read -> Oper, SBOw -> Oper and explicit Cancel orchestration; +- asynchronous application-error grace window; +- enhanced commands remain pending until correlated CommandTermination; +- missing termination is a control timeout, not an automatic association fault; +- C1-C4 are integrated into `ARIEC61850::core` and the normal GCC/Clang/MSVC CTest matrix. + +### C5 — controlled live interoperability harness + +- `ariec61850_control_interop_probe` is read-only by default; +- any live control Write requires the exact `--arm IEC61850-LAB-CONTROL` token plus an explicit + object, action and value; +- supported deliberate lab actions are `operate`, `select-operate`, and `select-cancel`; +- status-before/status-after reads are captured when a suitable ST/MX object exists; +- all control Write references are counted for deterministic no-retry evidence; +- optional JSON evidence records ctlModel, CDC, status, Write list/count, report observations, + completion, raw ControlError/AddCause and mapped AddCause; +- Wireshark filter guidance lets JSON evidence be reconciled with PCAP/PCAPNG; +- a standalone CMake profile, Windows PowerShell build helper and GCC/Clang/MSVC CI artifacts + make the harness directly usable in a laboratory. + +Current software state: + +`SOFTWARE_READY_FOR_LAB` + +Physical/simulator evidence is still required before changing a tested profile to: + +`LAB_INTEROP_PASSED` + +The required procedure is documented in `docs/CONTROL_INTEROP_RUNBOOK.md`. + +## C5 laboratory acceptance order + +1. Read-only live ctlModel/GVAA/status discovery with zero control Writes. +2. Direct normal: exactly one Oper Write plus status/process feedback. +3. SBO normal: SBO Read -> exactly one Oper Write; separately Select -> Cancel. +4. Direct enhanced: exactly one Oper Write -> correlated CommandTermination. +5. SBO enhanced: one SBOw Write -> one Oper Write -> correlated CommandTermination. +6. Safe negative LastApplError/AddCause case where the simulator/lab IED can provide it. +7. Association loss during SBO selection and enhanced-termination wait. +8. Reconcile JSON Write counts/references with retained PCAP and prove no automatic retry. + +Physical command testing must remain on an isolated/non-operational laboratory IED or simulator. +Hosted CI does not issue a physical command and is not IEC 61850 conformance certification. -Phase 4D must not begin physical command testing until Phase 4C.1 transport/model identity and -same-target read-only parity are accepted. +## Next implementation priorities after accepted C5 evidence -### Later work +1. MMS file and fault-record transfer parity. +2. Dynamic DataSet define/delete and the remaining dynamic RCB lifecycle parity. +3. Mutable SCL workspace/export and live-discovery-to-SCL reconstruction parity. +4. Deterministic IED simulator/server integration needed for repeatable multi-client regression. +5. CLI/application and native engineering-tool parity where product scope requires it. -- BRCB EntryID resume, replay, purge, and overflow recovery; -- dynamic DataSet lifecycle; -- MMS file and fault-record transfer; -- deterministic IED simulator/read-only server; -- CLI parity, Windows Npcap abstraction, and native applications. +Sampled Values / ESP32-P4 process-bus development is intentionally maintained separately from +this MMS-control tranche. diff --git a/PORT_STATUS.md b/PORT_STATUS.md index 0c1da50e2..83c0b7ad6 100644 --- a/PORT_STATUS.md +++ b/PORT_STATUS.md @@ -2,7 +2,7 @@ ## Current milestone -**Phase 4D-C4 — guarded IEC 61850 control-model software parity is implemented for review: live `ctlModel`/TypeSpecification discovery, Direct/SBO normal and enhanced sequencing, exact `Oper`/`SBOw`/`Cancel` structures, CommandTermination correlation, and LastApplError/AddCause diagnostics.** +**Phase 4D-C5 — guarded IEC 61850 control-model software parity is complete enough for controlled laboratory interoperability: live `ctlModel`/TypeSpecification discovery, Direct/SBO normal and enhanced sequencing, exact `Oper`/`SBOw`/`Cancel` structures, CommandTermination correlation, LastApplError/AddCause diagnostics, and a read-only-by-default live evidence harness are implemented for review.** The active Phase 4D work is intentionally carried as a stacked draft-PR series. `main` does not yet represent this milestone. @@ -31,6 +31,7 @@ Sampled Values / ESP32-P4 process-bus work is developed and validated on a separ | Live-type Oper/SBOw/Cancel MMS structure builder | Phase 4D-C2 / PR #27 | | CommandTermination + LastApplError/AddCause correlation | Phase 4D-C3 / PR #28 | | Live control discovery/session over MmsAssociationRuntime | Phase 4D-C4 / PR #29 | +| Guarded live control interoperability/evidence harness | Phase 4D-C5 / PR #30 | ## Phase 4D-C control behavior @@ -55,7 +56,29 @@ Sampled Values / ESP32-P4 process-bus work is developed and validated on a separ - Missing CommandTermination expires as a control timeout and does not automatically fault an otherwise healthy MMS association. - **No automatic command retry is performed.** -## Control validation completed without issuing a physical command +## C5 interoperability harness + +`ariec61850_control_interop_probe` is the acceptance harness for controlled live testing. + +Safety and evidence properties: + +- read-only discovery is the default; +- a control Write is rejected unless the exact `--arm IEC61850-LAB-CONTROL` token is supplied; +- the operator must still provide an explicit object, action, and value; +- supported lab actions are `operate`, `select-operate`, and `select-cancel`; +- SPC, DPC, integer, unsigned, floating-point and step-position values are supported conservatively; +- origin category/identifier, Test, synchrocheck and interlock-check are explicit; +- live status is read before and after the action when the IED exposes a usable ST/MX reference; +- every control Write service reference is counted and written to evidence; +- optional JSON evidence includes ctlModel, CDC, status, Write count/list, report-observation counts, completion, raw ControlError/AddCause and mapped AddCause; +- the tool prints the Wireshark MMS/TCP filter required to reconcile JSON with PCAP/PCAPNG; +- offline `--self-test` verifies the read-only default and arm gate without network access; +- a standalone CMake profile and Windows PowerShell build helper are provided; +- the dedicated C5 CI builds GCC, Clang and Windows/MSVC variants and publishes the tested harness binary with its self-test/help evidence. + +The laboratory procedure and evidence bundle format are defined in `docs/CONTROL_INTEROP_RUNBOOK.md`. + +## Control software validation completed without issuing a physical command Dedicated strict profiles currently cover: @@ -70,22 +93,25 @@ Dedicated strict profiles currently cover: - live descriptor-discovery orchestration using `ctlModel`, GVAA, GetNameList, CF timeouts, and ST/MX status-reference priority; - Direct one-write behavior, SBO Select -> Oper, SBOw asynchronous application-error window, AutoSelect, stale-selection Cancel, and enhanced termination wait; - production `MmsAssociationControlTransport` strict compile validation; -- integration of C1-C4 sources into the normal `ARIEC61850::core` build graph and normal CTest targets. +- integration of C1-C4 sources into the normal `ARIEC61850::core` build graph and normal CTest targets; +- C5 harness offline safety gate and cross-platform build profile. ## Remaining control acceptance gates -The software path is ready for controlled interoperability testing, but **physical IED control acceptance is not yet claimed**. +The software path and live harness are ready for controlled interoperability testing, but **physical IED control acceptance is not yet claimed**. Before any production-control or conformance claim, run on an isolated/non-operational laboratory IED or simulator and retain evidence for: -1. live `ctlModel` and GVAA discovery for a known controllable object; +1. live `ctlModel` and GVAA discovery for a known controllable object with `mmsControlWriteCount=0`; 2. Direct normal `Oper` with exactly one command Write and observed process/status feedback; 3. SBO normal `SBO -> Oper` plus explicit `Cancel` and selection-timeout behavior; 4. Direct enhanced `Oper -> CommandTermination` positive and at least one safe negative LastApplError/AddCause path; 5. SBO enhanced `SBOw -> Oper -> CommandTermination` plus ownership/contention behavior; 6. association loss during selection and while waiting for enhanced termination; 7. interlock/synchrocheck behavior only where the lab setup can exercise it safely; -8. packet capture and deterministic event log proving no automatic command retry. +8. packet capture and JSON/event evidence proving no automatic command retry. + +An individual tested profile may move from `SOFTWARE_READY_FOR_LAB` to `LAB_INTEROP_PASSED` only when its retained JSON and PCAP evidence agree. ## BRCB / non-volatile storage boundary @@ -95,7 +121,8 @@ A board successfully booting/flashing another ESP32-P4 application does not by i ## Safety / claim boundary -- The C4 branch implements software parity and deterministic validation; it is **not** an IEC 61850 conformance certificate. +- The C5 branch implements software parity plus the controlled live-test harness; it is **not** an IEC 61850 conformance certificate. - No physical IED command is claimed from hosted CI. -- Live control must remain explicit, authorization-gated, and laboratory-scoped until interoperability evidence is accepted. +- Live control remains explicit, authorization-gated, and laboratory-scoped until interoperability evidence is accepted. +- The harness is read-only by default and refuses an unarmed Write. - Sampled Values / ESP32-P4 development remains outside this control branch. diff --git a/docs/CONTROL_INTEROP_ACCEPTANCE.md b/docs/CONTROL_INTEROP_ACCEPTANCE.md new file mode 100644 index 000000000..e96b3b585 --- /dev/null +++ b/docs/CONTROL_INTEROP_ACCEPTANCE.md @@ -0,0 +1,239 @@ +# IEC 61850 Control Interoperability Acceptance Record + +## Scope + +This record captures the Phase 4D-C5 live simulator interoperability evidence gathered on 2026-08-11 against an isolated IEC 61850 vendor simulator. + +It documents observed interoperability of the guarded C++ control path. It is not an IEC 61850 conformance certificate and is not evidence of physical primary-plant or production-relay control. + +## Tested profile + +- Endpoint: `192.168.1.10:102` +- Simulator model/profile: `BCU7SL` +- Control object: `BCU7SLCTRL/CSWI1.Pos` +- Discovered CDC: `SPC` +- Live `ctlVal` TypeSpecification: `boolean` +- Status object: `BCU7SLCTRL/CSWI1$ST$Pos$stVal` +- `sboTimeout`: 30000 ms +- `operTimeout`: 1000 ms +- Origin used by the harness: `ARSTACK-C5`, station control +- Interlock-check: enabled +- Synchro-check: enabled +- Harness safety gate: exact `--arm IEC61850-LAB-CONTROL` +- Automatic command retry: disabled + +The live descriptor discovery also confirmed exact named structures for `Oper`, `SBOw` where applicable, and `Cancel`. The control path followed the live TypeSpecification instead of guessing a positional layout. + +## Acceptance matrix + +| ctlModel | Model | Tested path | Result | Observed command Writes | +|---|---|---|---|---| +| 1 | Direct with normal security | OFF and ON | PASS | exactly one `Oper` per deliberate action | +| 2 | SBO with normal security | Select/Operate OFF, Select/Operate ON, Select/Cancel | PASS | exactly one `Oper` per operate action; exactly one `Cancel` for cancel | +| 3 | Direct with enhanced security | OFF and ON | PASS | exactly one `Oper` per deliberate action | +| 4 | SBO with enhanced security | SBOw/Operate OFF and ON | PASS | exactly `SBOw`, then `Oper` per successful action | + +Overall tested-profile state: **SIMULATOR_INTEROP_PASSED**. + +## Direct normal — ctlModel 1 + +Raw discovery reported `numeric=1`, interpreted as `direct-normal`. + +### OFF + +- status before: `0x0680` +- status after: `0x0640` +- completion: `accepted` +- accepted: `true` +- termination: `false` +- command Writes: 1 +- Write: `BCU7SLCTRL/CSWI1$CO$Pos$Oper` + +### ON + +- status before: `0x0640` +- status after: `0x0680` +- completion: `accepted` +- accepted: `true` +- termination: `false` +- command Writes: 1 +- Write: `BCU7SLCTRL/CSWI1$CO$Pos$Oper` + +This matches the normal-security completion boundary: successful MMS Oper service acceptance completes the command; CommandTermination is not required. + +Local evidence filenames produced by the harness: + +- `bcu7sl-direct-normal-off.json` +- `bcu7sl-direct-normal-on.json` + +## SBO normal — ctlModel 2 + +Raw discovery reported `numeric=2`, interpreted as `sbo-normal`. + +The model required Select and did not require CommandTermination. + +### Select / Operate OFF + +- completion: `accepted` +- accepted: `true` +- termination: `false` +- command Writes: 1 +- Write: `BCU7SLCTRL/CSWI1$CO$Pos$Oper` + +### Select / Operate ON + +- completion: `accepted` +- accepted: `true` +- termination: `false` +- command Writes: 1 +- Write: `BCU7SLCTRL/CSWI1$CO$Pos$Oper` + +For this simulator, status could converge shortly after the normal-security MMS completion boundary. A subsequent deliberate command observed the expected new state. The harness therefore does not redefine normal-security completion as a status-change wait. + +### Select / Cancel + +- completion: `accepted` +- accepted: `true` +- termination: `false` +- command Writes: 1 +- Write: `BCU7SLCTRL/CSWI1$CO$Pos$Cancel` +- no `Oper` Write followed + +The SBO normal Select service is a Read of `SBO`, so the read is intentionally not counted in `mmsControlWriteCount`. + +Local evidence filenames produced by the harness: + +- `bcu7sl-sbo-normal-off.json` +- `bcu7sl-sbo-normal-on.json` +- `bcu7sl-sbo-normal-cancel.json` + +## Direct enhanced — ctlModel 3 + +Raw discovery reported `numeric=3`, interpreted as `direct-enhanced`. + +The descriptor reported `requiresSelect=false`, `enhanced=true`, and `commandTermination=true`. + +### OFF + +- status before: `0x0680` +- status after: `0x0640` +- completion: `positive-termination` +- accepted: `true` +- termination: `true` +- command Writes: 1 +- Write: `BCU7SLCTRL/CSWI1$CO$Pos$Oper` + +### ON + +- status before: `0x0640` +- status after: `0x0680` +- completion: `positive-termination` +- accepted: `true` +- termination: `true` +- command Writes: 1 +- Write: `BCU7SLCTRL/CSWI1$CO$Pos$Oper` + +Both directions completed only after a positive correlated CommandTermination. MMS Write acceptance alone was not treated as enhanced-security completion. + +Local evidence filenames produced by the harness: + +- `bcu7sl-direct-enhanced-off.json` +- `bcu7sl-direct-enhanced-on.json` + +## SBO enhanced — ctlModel 4 + +Raw discovery reported `numeric=4`, interpreted as `sbo-enhanced`. + +The descriptor reported `requiresSelect=true`, `enhanced=true`, and `commandTermination=true`. + +Live TypeSpecification discovery reported the named structures: + +```text +Oper=structure(ctlVal:boolean,origin:structure(orCat:integer,orIdent:octet-string),ctlNum:unsigned,T:utc-time,Test:boolean,Check:bit-string) +SBOw=structure(ctlVal:boolean,origin:structure(orCat:integer,orIdent:octet-string),ctlNum:unsigned,T:utc-time,Test:boolean,Check:bit-string) +Cancel=structure(ctlVal:boolean,origin:structure(orCat:integer,orIdent:octet-string),ctlNum:unsigned,T:utc-time,Test:boolean) +``` + +### OFF + +- status before: `0x0680` +- status after: `0x0640` +- completion: `positive-termination` +- accepted: `true` +- termination: `true` +- command Writes: 2 +- Write 1: `BCU7SLCTRL/CSWI1$CO$Pos$SBOw` +- Write 2: `BCU7SLCTRL/CSWI1$CO$Pos$Oper` + +### ON after OFF + +- status before: `0x0640` +- status after: `0x0680` +- completion: `positive-termination` +- accepted: `true` +- termination: `true` +- command Writes: 2 +- Write 1: `BCU7SLCTRL/CSWI1$CO$Pos$SBOw` +- Write 2: `BCU7SLCTRL/CSWI1$CO$Pos$Oper` + +### Safe rejection / no-retry evidence + +An earlier SBOw attempt was rejected by the simulator before Oper. The harness recorded: + +- completion: `rejected` +- accepted: `false` +- command Writes: 1 +- only Write: `BCU7SLCTRL/CSWI1$CO$Pos$SBOw` +- no `Oper` Write followed +- no automatic retry followed + +The exact application-side reason was not established by retained LastApplError/PCAP evidence, so this record does not assign a specific rejection cause. + +Local evidence filenames produced by the harness: + +- `bcu7sl-sbo-enhanced-off.json` +- `bcu7sl-sbo-enhanced-on-after-off.json` +- `bcu7sl-sbo-enhanced-on.json` (rejected SBOw case before the successful opposite-direction sequence) + +## Type-safety evidence + +Before the successful SPC tests, an intentionally incorrect `--value-kind dpc` attempt was rejected locally because the live `ctlVal` TypeSpecification was Boolean: + +```text +Control value does not match live ctlVal TypeSpecification at ctlVal. +``` + +No valid control sequence was sent for the mismatched value type. This confirms the live TypeSpecification gate prevents the client from guessing a DPC encoding for an SPC/Boolean command. + +## No-retry evidence + +Across the accepted cases, the harness reported exact command Write counts matching the expected control model: + +- Direct normal: 1 `Oper` +- SBO normal Operate: 1 `Oper` after Select Read +- SBO normal Cancel: 1 `Cancel` after Select Read +- Direct enhanced: 1 `Oper` +- SBO enhanced: 1 `SBOw` + 1 `Oper` + +The rejected SBO enhanced selection stopped after the single rejected `SBOw`. No automatic second selection or Oper was generated. + +## Claim boundary + +This evidence is sufficient to label this specific tested simulator profile: + +`SIMULATOR_INTEROP_PASSED` + +It does **not** establish: + +- IEC 61850 conformance certification; +- interoperability with every vendor implementation; +- physical IED / relay acceptance; +- energized primary-plant control suitability; +- negative LastApplError/AddCause coverage for every rejection mode; +- association-loss coverage for every control phase. + +Those remain separate acceptance gates. + +## Retention note + +The JSON filenames above were generated on the operator workstation. For audit-grade retention, pair each JSON with its matching PCAP/PCAPNG and simulator/event evidence according to `docs/CONTROL_INTEROP_RUNBOOK.md`. The JSON `mmsControlWriteCount` / `mmsControlWrites` values must agree with the packet capture before upgrading any physical-IED claim. diff --git a/docs/CONTROL_INTEROP_RUNBOOK.md b/docs/CONTROL_INTEROP_RUNBOOK.md new file mode 100644 index 000000000..e1c9d06dc --- /dev/null +++ b/docs/CONTROL_INTEROP_RUNBOOK.md @@ -0,0 +1,297 @@ +# IEC 61850 Control Interoperability Runbook + +## Purpose + +This runbook is the physical/laboratory acceptance procedure for Phase 4D-C5. +It validates the guarded C++ control path against a real IED or a vendor simulator. +It is not an IEC 61850 conformance certificate. + +Sampled Values / ESP32-P4 testing is deliberately out of scope here. + +## Safety boundary + +Use only an isolated, non-operational laboratory IED or simulator whose controlled +output cannot operate primary plant. Verify the physical/control circuit is safe +before arming any Write. + +`ariec61850_control_interop_probe` is read-only by default. A control Write is +blocked unless the exact token below is supplied: + +```text +--arm IEC61850-LAB-CONTROL +``` + +The harness never retries a command automatically. + +## Build + +### Windows / MSVC + +```powershell +cmake -S tools/control_interop_probe -B build-control-interop +cmake --build build-control-interop --config Release --parallel +ctest --test-dir build-control-interop -C Release --output-on-failure +``` + +Executable: + +```text +build-control-interop\Release\ariec61850_control_interop_probe.exe +``` + +### Linux + +```bash +cmake -S tools/control_interop_probe -B build-control-interop +cmake --build build-control-interop --parallel +ctest --test-dir build-control-interop --output-on-failure +``` + +Executable: + +```text +build-control-interop/ariec61850_control_interop_probe +``` + +## 0. Offline safety gate + +Run this before connecting to an IED: + +```bash +ariec61850_control_interop_probe --self-test +``` + +Expected: + +```text +CONTROL_INTEROP_SELF_TEST PASS readOnlyDefault=true armGate=true noAutoRetry=true +``` + +## 1. Start packet capture + +Start Wireshark before connecting. + +Recommended display filter after the run: + +```text +tcp.port == 102 && ip.addr == +``` + +Retain the original PCAP/PCAPNG together with the JSON evidence produced by the +harness. Keep capture timestamps synchronized with the engineering workstation. + +## 2. Read-only discovery gate + +Example: + +```bash +ariec61850_control_interop_probe 192.168.1.10 102 \ + --object LD0/CSWI1.Pos \ + --evidence c5-discovery.json +``` + +This may perform MMS Read, GVAA and GetNameList, but no Write. + +Accept when the output proves: + +- the object root resolves as `LD/LN.DO`; +- `ctlModel` is one of Direct normal, SBO normal, Direct enhanced or SBO enhanced; +- exact live `Oper` TypeSpecification is available; +- `SBOw` TypeSpecification is available for SBO enhanced; +- `Cancel` TypeSpecification is available for an SBO model; +- the inferred CDC/control value type is credible; +- `sboTimeout` / `operTimeout` are read when exposed, otherwise bounded fallbacks are explicit; +- the best matching ST/MX status object is identified when exposed; +- evidence JSON reports `mmsControlWriteCount: 0`. + +Do not proceed to Write if discovery is incomplete or vendor-specific command +fields cannot be bound conservatively. + +## 3. Direct normal acceptance + +Example DPC command: + +```bash +ariec61850_control_interop_probe 192.168.1.10 102 \ + --object LD0/CSWI1.Pos \ + --action operate \ + --value on \ + --value-kind dpc \ + --origin ARSTACK-C5 \ + --origin-category station \ + --interlock-check on \ + --synchro-check on \ + --arm IEC61850-LAB-CONTROL \ + --evidence c5-direct-normal-on.json +``` + +Acceptance: + +- discovered model is `direct-normal`; +- exactly one `CO/.../Oper` Write is recorded; +- no second command Write appears in the PCAP; +- MMS service completion is accepted; +- status feedback is recorded when the IED exposes a readable ST/MX value. + +Repeat only as a new deliberate operator action if an opposite command is needed. +A second manual action is not an automatic retry. + +## 4. SBO normal acceptance + +Use explicit sequencing so Select and Oper are visible as separate phases: + +```bash +ariec61850_control_interop_probe 192.168.1.10 102 \ + --object LD0/CSWI1.Pos \ + --action select-operate \ + --value on \ + --value-kind dpc \ + --arm IEC61850-LAB-CONTROL \ + --evidence c5-sbo-normal-operate.json +``` + +Acceptance: + +- discovered model is `sbo-normal`; +- Select is an MMS Read of `SBO`; +- selection response identifies the selected object or an accepted opaque token; +- Oper uses the immutable selected sequence; +- exactly one command Write is `Oper`; +- no automatic retry appears. + +Explicit Cancel test: + +```bash +ariec61850_control_interop_probe 192.168.1.10 102 \ + --object LD0/CSWI1.Pos \ + --action select-cancel \ + --value on \ + --value-kind dpc \ + --arm IEC61850-LAB-CONTROL \ + --evidence c5-sbo-normal-cancel.json +``` + +Acceptance: + +- SBO Select succeeds; +- exactly one `Cancel` Write releases the selection; +- no Oper Write is sent. + +Selection timeout should be tested only with a deliberate bounded lab procedure. +Do not automate repeated Select attempts. + +## 5. Direct enhanced acceptance + +```bash +ariec61850_control_interop_probe 192.168.1.10 102 \ + --object LD0/CSWI1.Pos \ + --action operate \ + --value on \ + --value-kind dpc \ + --termination-timeout-ms 10000 \ + --arm IEC61850-LAB-CONTROL \ + --evidence c5-direct-enhanced.json +``` + +Acceptance: + +- exactly one `Oper` Write is sent; +- MMS Write acceptance alone does not complete the command; +- ordinary ST/MX reports are ignored as completion; +- a correlated `CommandTermination` completes the command; +- positive termination is recorded as `positive-termination`, or a negative + LastApplError path records ControlError/AddCause without a retry; +- lack of termination becomes a control timeout without automatically faulting a + healthy MMS association. + +## 6. SBO enhanced acceptance + +```bash +ariec61850_control_interop_probe 192.168.1.10 102 \ + --object LD0/CSWI1.Pos \ + --action select-operate \ + --value on \ + --value-kind dpc \ + --termination-timeout-ms 10000 \ + --arm IEC61850-LAB-CONTROL \ + --evidence c5-sbo-enhanced.json +``` + +Acceptance: + +- one `SBOw` Write establishes the value-bearing selection; +- the asynchronous application-error grace window is observed; +- one `Oper` Write follows only after successful selection; +- correlated CommandTermination is required for completion; +- JSON/PCAP therefore show two deliberate command-service Writes (`SBOw`, `Oper`), + not duplicate retries of either service. + +Explicit Cancel test uses `--action select-cancel`. For SBO enhanced the expected +write sequence is `SBOw`, then `Cancel`. + +## 7. Negative-path evidence + +Capture at least one negative enhanced-security result when it can be produced +safely by the lab IED/simulator. Preferred cases are a simulator-configured +interlock or synchrocheck rejection. + +Acceptance evidence should preserve: + +- raw ControlError; +- raw AddCause; +- mapped AddCause text such as `blocked-by-interlocking` or + `blocked-by-synchrocheck`; +- the exact control object; +- the number and identity of Write services; +- proof that no retry follows the rejection. + +Do not deliberately defeat a real interlock merely to create a negative test. + +## 8. Association-loss evidence + +Use a simulator or otherwise safe lab setup. Establish an SBO selection, then +cause the MMS association to disappear before Oper. Verify the client fails +closed and the next command requires a new explicit action/selection. + +For enhanced control, separately exercise association loss while waiting for +CommandTermination where possible. + +## Evidence bundle + +For each accepted case retain: + +```text +C5___/ + evidence.json + capture.pcapng + notes.txt + optional_ied_event_log.txt +``` + +Minimum `notes.txt` content: + +```text +IED vendor/model/firmware: +IED IP: +Control object: +ctlModel: +Test case: +Expected safe plant/simulator response: +Observed response: +PC clock/time source: +Operator: +Date/time: +``` + +The JSON field `mmsControlWriteCount` and `mmsControlWrites` must agree with the +PCAP. A mismatch is a failed evidence gate and must be investigated before any +production-control claim. + +## Acceptance state + +Hosted CI can mark the harness `SOFTWARE_READY_FOR_LAB`. + +Only retained physical/simulator evidence for the required model(s) can move the +specific tested IED profile to `LAB_INTEROP_PASSED`. + +Neither state means IEC 61850 conformance certification. diff --git a/docs/FEATURE_MATRIX.md b/docs/FEATURE_MATRIX.md index 7cd162130..1655d7fc2 100644 --- a/docs/FEATURE_MATRIX.md +++ b/docs/FEATURE_MATRIX.md @@ -3,91 +3,98 @@ Status terms are intentionally conservative: - **Live-proven** — exercised against a reachable IED/vendor simulator endpoint. -- **Offline-tested** — deterministic unit/integration/fuzz evidence exists, but no - current live-device claim is made. +- **Offline-tested** — deterministic unit/integration/fuzz/CI evidence exists, but no current live-device claim is made. +- **Software-ready for lab** — the production path and guarded live-test harness are implemented; retained physical/simulator evidence is still required. - **Partial** — useful implementation exists but important parity/interop gaps remain. - **Planned** — not implemented as a usable public feature yet. | Area | Feature | Status | Current evidence / boundary | |---|---|---|---| -| Transport | TCP client transport | Live-proven | Windows/POSIX transport used by live MMS discovery. | -| OSI | TPKT / COTP | Live-proven | Live association path reaches MMS endpoint. | -| OSI | Session / Presentation / ACSE | Live-proven | Current association profile + tolerant C#-compatible acceptance path interoperates with current test endpoint. | -| MMS client | Initiate / association lifecycle | Live-proven | Read-only discovery session associates successfully. Multi-profile reconnect retry is still incomplete. | -| MMS client | GetNameList domains | Live-proven | Current OCR7SR12 evidence discovers 4 domains. | -| MMS client | GetNameList variables | Live-proven | Thousands of MMS variables discovered live; inventory can vary with exposed runtime/configuration state. | -| MMS client | GetVariableAccessAttributes | Live-proven | C++ LN-root planner completed 123/123 probes and projected exact MMS types across 6990 canonical DA on the current endpoint. | -| MMS client | Read | Live-proven | Full current OCR7SR12 RCB inventory was probed read-only: 286/286 represented, 0 NotRead and 0 ReadFailed in the supplied full-probe snapshot. | -| MMS client | GetNamedVariableListAttributes | Live-proven/optional | Used when DataSet directory reads are enabled; may be deliberately skipped for structural-only runs. | -| MMS client | GO/SV/SG/LG exact-attribute deep Read | SGCB live-proven / integrated path offline-tested | Standalone bounded reader physically read `OCR7SR12PROT/LLN0.SP.SGCB` 5/5 (`ActSG`, `CnfEdit`, `EditSG`, `LActTm`, `NumOfSG`). The new `live_discover --control-block-values` overlay is CI-tested and awaits one integrated physical regression. | -| Live model | LD/LN/DO/DA projection | Live-proven | Same-IED OCR7SR12 evidence: 4 LD, 123 LN, 1186 DO, 6990 DA; C#↔C++ structural comparison has blocking=0. | -| Live model | IED identity resolver | Live-proven | OCR7SR12 inferred from logical-device domain suffix evidence. | -| Live model | CDC inference | Live-proven + offline-tested | C# registry/pattern port used by live model; exact CDC still depends on available evidence. | -| Live model | TypeTemplates | Live-proven | Live C# and C++ captures canonicalize to the same 471 template identities; raw projection contains 1309 candidates in both current captures. | -| Live model | VariableTypeDiscoveries | Live-proven | C# direct probes and C++ LN-root probes use different request strategies; common projected DA type signatures compare with blocking=0. | -| Live model | Structural fingerprint | Live-proven / stability pending | Current live capture emits a structure-only fingerprint excluding mutable DataSet/RCB runtime state; repeated-cycle stability remains an acceptance gate. | -| Live model | Runtime snapshot fingerprint | Live-proven | Current live capture includes mutable DataSet inventory and RCB binding/enable/reservation evidence separately from structural identity. | -| Live model | Control-block runtime evidence overlay | Offline-tested / physical integration pending | Mutable GO/SV/SG/LG values are projected after structural model build, so deep runtime evidence does not change the canonical structural fingerprint. Complete/partial/failed/bounded states are reported explicitly. | -| Cross-language | Same-IED C# ↔ C++ structural comparator | Live-proven | OCR7SR12 structural comparison is PASS with blocking=0 and totalFindings=0; `--types` is also PASS with blocking=0 and totalFindings=0 on common exact type evidence. | -| Cross-language | Same-IED runtime comparator | Live-proven / mutable | Runtime comparison is informational; C++-only `DataSetBindingStatus` is treated as enrichment rather than a false cross-schema mismatch. | -| DataSet | Name inventory | Live-proven | Dynamic inventory may legitimately change between sessions. | -| DataSet | Member directory | Live-proven/optional | Explicitly skipped with `--no-datasets`; empty member list then produces evidence warning, not structural failure. | -| Reporting | RCB inventory | Live-proven | Current endpoint exposes 286 RCB identities: 8 BRCB and 278 URCB in the C# oracle capture. | -| Reporting | Full RCB read-only probe | Live-proven | Current C++ full-probe snapshot classified all 286 RCBs with 0 NotRead and 0 ReadFailed. | -| Reporting | Bound/Unbound runtime semantics | Live-proven | One current snapshot observed 6 Bound and 280 Unbound. This is mutable runtime state, not a permanent device capability claim. | -| Reporting | Dynamic RCB candidate planner | Live-proven | Read-only ranking selected an empty dynamic BRCB candidate on current endpoint. | -| Reporting | Conservative operational availability | Live-proven | Separates dynamic-slot eligibility from populated/static report availability. | -| Reporting | Read-only contention pre-claim probe | Live-proven | Three repeated probes on `OCR7SR12CTRL/BI6GGIO1.urcbA01` remained free/stable and returned `StableProceed`; no claim/write was performed. | -| Reporting | RCB claim/reservation/enable | Planned / out of Phase 4C | No Write, reservation, `RptEna` or GI is authorized by live discovery. | -| Reporting | InformationReport decode / subscription runtime | Offline-tested / partial live integration | Core implementation exists; full live reporting acceptance is not claimed. | -| GOOSE | PDU / Ethernet encode-decode | Offline-tested | Golden vectors, deterministic tests and fuzz corpus. | -| GOOSE | Publisher/subscriber runtime foundations | Offline-tested | Physical multi-vendor process-bus acceptance pending. | -| Sampled Values | PDU / Ethernet encode-decode | Offline-tested | Golden vectors, deterministic tests and fuzz corpus. | -| Sampled Values | Caller-owned bounded encode path | Offline-tested | `encode_into(span)` avoids fresh frame-buffer allocation in steady-state publisher design. | -| Sampled Values | Live MCU publisher | Planned / embedded path staged | First reference hardware is the Waveshare ESP32-S3-POE-ETH-8DI-8DO; synthetic W5500/ESP-IDF MACRAW transmission proof is required before any timing-grade claim. | -| SCL | Read-only parser / engineering support | Offline-tested | Host-only engineering surface; not part of MCU runtime core. | -| COMTRADE | CFG/DAT read support | Offline-tested | Host engineering feature. | -| PCAP | Read/write/evidence support | Offline-tested | Host evidence feature. | -| Embedded | `ARIEC61850::embedded_core` boundary | Offline-tested | Host-only services excluded by dedicated build profile. | -| Embedded | no-RTTI profile | Offline-tested | GCC/Clang host-simulation gate. | -| Embedded | no-exceptions MCU core | Planned | Legacy protocol error paths still contain exception debt. | -| Embedded | ESP32-S3 W5500 / ESP-IDF Ethernet adapter | Planned | Reference design uses ESP-IDF W5500 MACRAW integration with raw Ethernet for SV/GOOSE and `esp_netif`/lwIP for MMS TCP on the same interface. | -| Embedded | ESP32-S3 8DI/8DO IED application | Planned | Waveshare board application reference after bounded MMS server foundation exists. | -| MMS server | Association acceptor + static browse/read model | Planned | Required for the ESP32 I/O IED; not yet a public server feature. | -| MMS mutation | Write/control | Codec/offline foundation only | Live Phase 4C discovery intentionally does not send these operations. | -| File services | File directory/read/write | Planned for later parity | No file-service mutation/request is part of current live acceptance. | -| Security | ASan/UBSan | CI-gated | Security workflow; latest tranche must be green before acceptance claim. | -| Security | libFuzzer corpora | CI-gated | BER, GOOSE, SV, PCAP, SCL, COMTRADE, OSI, MMS services/reporting. | -| Portability | GCC / Clang / MSVC | CI-gated | Required for host Public Alpha. | -| Acceptance | Same-IED repeated structural stability | In progress | Acceptance runner uses `structuralFingerprint`, not mutable legacy canonical fingerprint. Multi-cycle live evidence is the next A1 acceptance tranche. | -| Acceptance | Timeout/reconnect evidence | In progress | Dedicated controlled failure/reconnect runs remain required; successful association cycles alone are not sufficient evidence. | -| Acceptance | Integrated control-block deep-read regression | Pending one physical run | Standalone SGCB 5/5 is live-proven; the newly wired `live_discover --control-block-values` path must now reproduce the result on OCR7SR12. | -| Acceptance | Multi-vendor physical interoperability | Not yet complete | Required before full replacement/industrial claims. | +| Transport | TCP client transport | Live-proven | Windows/POSIX transport is used by live MMS discovery and is the transport under the C5 control adapter. | +| OSI | TPKT / COTP | Live-proven | Live association path reaches MMS endpoints; controlled timeout/recovery evidence has been accepted on OCR7SR12. | +| OSI | Session / Presentation / ACSE | Live-proven | Current association profiles and the C#-compatible fallback path interoperate with the exercised endpoint. | +| MMS client | Initiate / association lifecycle | Live-proven | Fresh association/reconnect cycles are accepted on the exercised OCR7SR12 evidence set. | +| MMS client | GetNameList domains/variables | Live-proven | OCR7SR12 evidence includes large-model pagination with final `moreFollows=false`. | +| MMS client | GetVariableAccessAttributes | Live-proven | 123/123 LN-root probes and exact projected MMS types across 6,990 canonical DA on the exercised endpoint. | +| MMS client | Read | Live-proven | Full OCR7SR12 RCB inventory represented 286/286 with zero NotRead/ReadFailed in the accepted read-only snapshot. | +| MMS client | Write codec/runtime | Offline-tested / live-control lab pending | Confirmed multi-variable Write is implemented; C5 routes control Writes through the production association runtime. Physical control acceptance is still pending. | +| MMS client | GetNamedVariableListAttributes | Live-proven/optional | Used when DataSet directory reads are enabled. | +| MMS client | GO/SV/SG/LG exact-attribute deep Read | Live-proven | OCR7SR12 SGCB deep read accepted 5/5 attributes without mutation. | +| Live model | LD/LN/DO/DA projection | Live-proven | OCR7SR12: 4 LD, 123 LN, 1,186 DO, 6,990 DA; C#↔C++ structural comparison blocking=0. | +| Live model | IED identity resolver | Live-proven | OCR7SR12 identity inferred from live logical-device evidence. | +| Live model | CDC inference | Live-proven + offline-tested | Conservative C#-oracle-derived inference; exact command binding still uses live TypeSpecification. | +| Live model | TypeTemplates | Live-proven | Current C# and C++ captures canonicalize to the same 471 template identities. | +| Live model | Structural fingerprint | Live-proven | Repeated primary-vendor cycles and timeout/recovery retained the accepted structure-only fingerprint. | +| Cross-language | Same-IED C# ↔ C++ structural/type comparator | Live-proven | OCR7SR12 structural and common exact-type comparison pass with zero blocking findings. | +| DataSet | Name inventory/member directory | Live-proven | Dynamic inventory may legitimately vary with runtime configuration. | +| DataSet | Dynamic define/delete | Planned for parity tranche | Generic service foundations exist, but full guarded live lifecycle parity remains later work. | +| Reporting | RCB inventory/read-only probe | Live-proven | Current endpoint exposes 286 RCB identities; accepted full probe classified all without read failures. | +| Reporting | URCB operational runtime | Offline-tested | Reservation, RptEna, GI/touched-state cleanup and event hard profiles are implemented; broad multi-vendor live reporting acceptance remains pending. | +| Reporting | BRCB retained replay / EntryID / PurgeBuf | Offline-tested | Bounded retained history, replay/resume/rewind, PurgeBuf, overflow/replay-gap handling and hard-profile evidence are implemented. | +| Reporting | BRCB Owner / ResvTms / association lifecycle | Offline-tested | Multi-client ownership, reconnect/expiry and association-loss semantics are implemented and hard-profile tested. | +| Reporting | BRCB recovery image v2 | Offline-tested / physical NVM pending | A/B recovery preserves retained window/cursor/gap with v1 restore; physical flash geometry/endurance/power-loss evidence is a separate gate. | +| Control | `ctlModel` discovery | Software-ready for lab | C5 reads live ctlModel and exact control TypeSpecifications before any optional Write. | +| Control | Direct normal | Software-ready for lab | C1-C4 deterministic semantics + C5 guarded live harness; exactly-one `Oper` physical evidence remains required. | +| Control | SBO normal | Software-ready for lab | Normal Select is `SBO` Read; immutable sequence, Oper and explicit Cancel are implemented. Physical SBO lifecycle evidence remains required. | +| Control | Direct enhanced | Software-ready for lab | Oper acceptance remains pending until correlated CommandTermination; ordinary ST/MX reports cannot complete the command. | +| Control | SBO enhanced | Software-ready for lab | One `SBOw` selection Write followed by one `Oper` Write and correlated CommandTermination; physical evidence remains required. | +| Control | `Oper` / `SBOw` / `Cancel` live TypeSpecification binding | Offline-tested | Golden MMS Data vectors cover ctlVal/origin/ctlNum/T/Test/Check and optional operTm; unknown vendor fields fail closed. | +| Control | Control value types | Offline-tested | SPC/DPC/integer/unsigned/floating/step-position binding is conservative; DPC uses network bit order. | +| Control | Association ownership / immutable sequence | Offline-tested | Second-client takeover, selection expiry, sequence mismatch, authorization revocation and association-loss cleanup are covered. | +| Control | CommandTermination | Offline-tested | Positive termination is exact `CO/Oper` correlated; enhanced command does not complete at Write acceptance. | +| Control | LastApplError / ControlError / AddCause | Offline-tested | Standard ControlError and AddCause 0..27 mappings plus raw unknown-code preservation are implemented. | +| Control | Automatic command retry | Disabled / CI-proven contract | C1-C5 deliberately perform no automatic retry. C5 records every control Write for reconciliation with PCAP. | +| Control | Live interoperability harness | Software-ready for lab | Read-only by default; exact `--arm IEC61850-LAB-CONTROL` token plus object/action/value is required before Write. JSON evidence and tested CI binaries are produced. | +| GOOSE | PDU / Ethernet encode-decode | Offline-tested | Golden vectors, deterministic tests and fuzz corpus. Physical multi-vendor process-bus acceptance remains separate. | +| GOOSE | Publisher/subscriber runtime foundations | Offline-tested | Physical process-bus acceptance is outside this control tranche. | +| Sampled Values | PDU / Ethernet encode-decode | Offline-tested | Process-bus work is maintained in its separate development/evidence thread; this matrix does not use that work as MMS-control evidence. | +| SCL | Read-only parser / engineering support | Offline-tested | Host engineering surface. Mutable standards-aware export/reconstruction parity remains later work. | +| COMTRADE | CFG/DAT read support | Offline-tested | ASCII/BINARY/BINARY32/FLOAT32 and mapping foundations are implemented. | +| PCAP | Read/write/evidence support | Offline-tested | Used as a retained evidence format; C5 expects Wireshark PCAP/PCAPNG to be reconciled with its JSON control Write log. | +| Embedded | no-RTTI hard profiles | Offline-tested | GCC/Clang embedded/hard-profile gates are maintained. | +| Embedded | no-exceptions full MCU codec | Partial | Some shared legacy validation/convenience paths still carry exception debt. | +| MMS server | Static association/browse/read/write foundations | Offline-tested / partial | Static server-side runtime and hard-profile components exist on the active stack; full deterministic IED simulator/server integration parity remains later work. | +| File services | File directory/read/write / fault records | Planned for next parity work | Not part of C5 control acceptance. | +| Security | ASan/UBSan | CI-gated | Security/Evidence workflow is required on active heads. | +| Security | libFuzzer corpora | CI-gated | BER, GOOSE, SV, PCAP, SCL, COMTRADE, OSI and MMS corpora are maintained. | +| Portability | GCC / Clang / MSVC | CI-gated | C1-C5 production/control paths build through the normal cross-platform matrix; C5 also has its own three-platform harness CI. | +| Acceptance | Read-only same-IED structural stability | Live-proven | Primary-vendor repeated-cycle and timeout/recovery evidence accepted on OCR7SR12. | +| Acceptance | Control C5 software gate | Software-ready for lab | Guarded harness, runbook, JSON evidence schema, no-retry accounting and CI binaries are implemented. | +| Acceptance | Physical IEC 61850 control interoperability | Pending | Direct normal, SBO normal, Direct enhanced, SBO enhanced, safe negative diagnostic and association-loss evidence must be retained per `CONTROL_INTEROP_RUNBOOK.md`. | +| Acceptance | Multi-vendor physical interoperability | Not yet complete | Required before industrial replacement or broad conformance claims. | -## Current Phase 4C mutation boundary +## Current live mutation boundary -The live discovery workflow may issue read-only MMS operations such as -GetNameList, GetVariableAccessAttributes, GetNamedVariableListAttributes and -Read. Optional `--control-block-values` performs bounded MMS Read only against -exact GO/SV/SG/LG variable names already returned by live `GetNameList`. It must -not perform Write, control, RCB reservation/enable, GoEna/SvEna mutation, GI, -setting-group mutation, dynamic DataSet mutation, or file-service mutation. +The established Phase 4C/4C.1 discovery tools remain read-only and may issue GetNameList, +GetVariableAccessAttributes, GetNamedVariableListAttributes and Read only. -## Current live parity milestone +Phase 4D-C introduces a separate explicit active-control API. The C5 interoperability harness +preserves the safe default: without an action it performs discovery only, and any Write is +rejected unless the caller supplies the exact laboratory arm token plus the target object, +action and value. It never performs an automatic command retry. -On the currently exercised OCR7SR12 endpoint, C# and C++ `live-ied-model-v1` -exports now agree structurally on LD/LN/DO/DA, RCB/control-block identities and -canonical type-template projection. Common exact DA MMS type signatures also -compare with zero blocking findings. A separate C++ read-only full-RCB snapshot -classified all 286 RCBs (6 Bound, 280 Unbound at that instant) with zero -NotRead/ReadFailed entries. The standalone deep reader also read the real SGCB -5/5 without mutation. These facts are evidence for this endpoint and -configuration only; they are not a multi-vendor or full-stack parity claim. +Reporting/BRCB mutation semantics are implemented in their bounded server/runtime hard profiles, +but broad live multi-vendor reporting acceptance is distinct from the C5 control gate. -## Public Alpha exit direction +## Current control milestone -A Public Alpha can be useful before full ARIEC61850 parity if it provides a -reproducible Windows/Linux build, stable read-only live discovery/model JSON, -structural C#↔C++ same-IED comparison, documented GOOSE/SV codecs, and honest -interop evidence. Full server/control/reporting/file/TLS parity is not an A1 -requirement. +C1-C4 provide software parity for the guarded control workflow and are integrated into +`ARIEC61850::core`. C5 adds the live laboratory harness and retained evidence contract. +The software state is therefore: + +`SOFTWARE_READY_FOR_LAB` + +A specific IED/control-model profile becomes: + +`LAB_INTEROP_PASSED` + +only after the JSON write count/reference evidence agrees with retained PCAP/PCAPNG and the +required positive/negative/association-loss behavior is accepted on an isolated laboratory IED +or simulator. Neither state is IEC 61850 conformance certification. + +## Next parity direction after C5 evidence + +The next highest-value migration gaps are MMS file/fault-record transfer, dynamic DataSet +lifecycle, mutable SCL export/reconstruction, deterministic IED simulator/server integration, +and then broader application/UI parity. Sampled Values / ESP32-P4 process-bus development stays +separate from this MMS-control tranche. diff --git a/scripts/build_control_interop.ps1 b/scripts/build_control_interop.ps1 new file mode 100644 index 000000000..28d95fe8e --- /dev/null +++ b/scripts/build_control_interop.ps1 @@ -0,0 +1,52 @@ +param( + [ValidateSet('Release','Debug')] + [string]$Configuration = 'Release', + [switch]$Clean +) + +$ErrorActionPreference = 'Stop' +$Root = Split-Path -Parent $PSScriptRoot +$Build = Join-Path $Root 'build-control-interop' + +if ($Clean -and (Test-Path $Build)) { + Remove-Item -Recurse -Force $Build +} + +Write-Host "[C5] Configuring IEC 61850 Control Interop harness..." +cmake -S (Join-Path $Root 'tools/control_interop_probe') -B $Build +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +Write-Host "[C5] Building $Configuration..." +cmake --build $Build --config $Configuration --parallel +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +Write-Host "[C5] Running offline safety tests..." +ctest --test-dir $Build -C $Configuration --output-on-failure +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +$CandidateA = Join-Path $Build "$Configuration\ariec61850_control_interop_probe.exe" +$CandidateB = Join-Path $Build 'ariec61850_control_interop_probe.exe' +$Exe = if (Test-Path $CandidateA) { $CandidateA } else { $CandidateB } + +$InventoryCandidateA = Join-Path $Build "$Configuration\ariec61850_control_inventory_probe.exe" +$InventoryCandidateB = Join-Path $Build 'ariec61850_control_inventory_probe.exe' +$InventoryExe = if (Test-Path $InventoryCandidateA) { $InventoryCandidateA } else { $InventoryCandidateB } + +if (-not (Test-Path $Exe)) { + throw "Build succeeded but control interop executable was not found." +} +if (-not (Test-Path $InventoryExe)) { + throw "Build succeeded but read-only control inventory executable was not found." +} + +Write-Host "" +Write-Host "[C5] READY: $Exe" +Write-Host "[C5] INVENTORY READY: $InventoryExe" +Write-Host "[C5] Safest first command for an unknown IED model (read-only, zero control Write):" +Write-Host " `"$InventoryExe`" 102 --evidence c5-control-inventory.json" +Write-Host "" +Write-Host "[C5] After inventory identifies an exact control object, descriptor discovery remains read-only:" +Write-Host " `"$Exe`" 102 --object --evidence c5-discovery.json" +Write-Host "" +Write-Host "Live Write remains locked unless the interop executable receives:" +Write-Host " --arm IEC61850-LAB-CONTROL" diff --git a/tools/control_ctlmodel_probe.cpp b/tools/control_ctlmodel_probe.cpp new file mode 100644 index 000000000..8ed7470fd --- /dev/null +++ b/tools/control_ctlmodel_probe.cpp @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/control_session.hpp" +#include "ariec61850/mms/data_codec.hpp" +#include "ariec61850/mms/live_discovery.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; +using namespace ar::iec61850::control; +using namespace ar::iec61850::mms; + +[[nodiscard]] const char* kind_name(const MmsDataKind kind) noexcept { + switch (kind) { + case MmsDataKind::array: return "array"; + case MmsDataKind::structure: return "structure"; + case MmsDataKind::boolean: return "boolean"; + case MmsDataKind::bit_string: return "bit-string"; + case MmsDataKind::integer: return "integer"; + case MmsDataKind::unsigned_integer: return "unsigned-integer"; + case MmsDataKind::floating_point: return "floating-point"; + case MmsDataKind::octet_string: return "octet-string"; + case MmsDataKind::visible_string: return "visible-string"; + case MmsDataKind::binary_time: return "binary-time"; + case MmsDataKind::bcd: return "bcd"; + case MmsDataKind::boolean_array: return "boolean-array"; + case MmsDataKind::object_id: return "object-id"; + case MmsDataKind::mms_string: return "mms-string"; + case MmsDataKind::utc_time: return "utc-time"; + case MmsDataKind::unknown: return "unknown"; + } + return "unknown"; +} + +[[nodiscard]] std::string raw_hex(const MmsDataValue& value) { + std::ostringstream output; + output << std::uppercase << std::hex << std::setfill('0'); + for (const auto byte : value.raw_value()) { + output << std::setw(2) << static_cast(byte); + } + return output.str(); +} + +[[nodiscard]] std::optional numeric_value(const MmsDataValue& value) noexcept { + if (value.kind() == MmsDataKind::integer) { + if (const auto* number = std::get_if(&value.value())) return *number; + } + if (value.kind() == MmsDataKind::unsigned_integer) { + if (const auto* number = std::get_if(&value.value())) { + if (*number <= static_cast(std::numeric_limits::max())) { + return static_cast(*number); + } + } + } + return std::nullopt; +} + +[[nodiscard]] std::string ctl_model_name(const std::optional value) { + if (!value) return "not-decoded"; + switch (*value) { + case 0: return "status-only"; + case 1: return "direct-normal"; + case 2: return "sbo-normal"; + case 3: return "direct-enhanced"; + case 4: return "sbo-enhanced"; + default: return "out-of-standard-range"; + } +} + +[[nodiscard]] std::uint16_t parse_port(const std::string& value) { + std::size_t consumed{}; + const auto parsed = std::stoul(value, &consumed, 10); + if (consumed != value.size() || parsed == 0UL || parsed > 65'535UL) { + throw std::invalid_argument("MMS TCP port must be in the range 1..65535."); + } + return static_cast(parsed); +} + +int run_self_test() { + const auto value = MmsDataValue::integer(2); + if (std::string{kind_name(value.kind())} != "integer" || + ctl_model_name(numeric_value(value)) != "sbo-normal") { + std::cerr << "CTLMODEL_RAW_SELF_TEST FAIL\n"; + return 1; + } + std::cout << "CTLMODEL_RAW_SELF_TEST PASS readOnly=true controlWritePath=false\n"; + return 0; +} + +void print_usage() { + std::cout + << "Usage:\n" + << " ariec61850_control_ctlmodel_probe [port] --object LD/LN.DO\n\n" + << "Safety: read-only diagnostic. Performs one MMS Read of CF/.../ctlModel.\n" + << "It has no Write action and no arm token.\n"; +} + +} // namespace + +int main(int argc, char** argv) { + try { + if (argc == 2 && std::string_view{argv[1]} == "--self-test") return run_self_test(); + if (argc < 2 || std::string_view{argv[1]} == "--help" || + std::string_view{argv[1]} == "-h") { + print_usage(); + return argc > 1 ? 0 : 2; + } + + MmsEndpoint endpoint; + endpoint.host = argv[1]; + endpoint.port = 102U; + int argument = 2; + if (argument < argc && std::string_view{argv[argument]}.find("--") != 0U) { + endpoint.port = parse_port(argv[argument]); + ++argument; + } + + std::string object_text; + while (argument < argc) { + const std::string option = argv[argument++]; + if (argument >= argc) throw std::invalid_argument(option + " requires a value."); + const std::string value = argv[argument++]; + if (option == "--object") object_text = value; + else throw std::invalid_argument("Unknown option: " + option); + } + if (object_text.empty()) throw std::invalid_argument("--object is required."); + + ControlObjectReference object; + if (!try_parse_control_object_reference(object_text, object)) { + throw std::invalid_argument("Control object must use LD/LN.DO form."); + } + std::array item_buffer{}; + std::size_t item_bytes{}; + if (!build_control_item(object, "CF", "ctlModel", item_buffer, item_bytes)) { + throw std::runtime_error("Cannot build ctlModel MMS item."); + } + const std::string item{item_buffer.data(), item_bytes}; + + MmsAssociationOptions association_options; + association_options.connect_timeout = std::chrono::milliseconds{5'000}; + association_options.request_timeout = std::chrono::milliseconds{5'000}; + MmsTcpLiveDiscoverySession live_session{{}, association_options}; + live_session.connect(endpoint); + MmsAssociationControlTransport transport{live_session.association()}; + + const auto mms_object = MmsObjectName::domain_specific(std::string{object.domain}, item); + const auto value = transport.read(mms_object); + if (!value) { + throw std::runtime_error("MMS Read returned no ctlModel value."); + } + + const auto numeric = numeric_value(*value); + std::cout << "CTLMODEL_RAW object=" << object_text + << " mms=" << mms_object.reference() + << " kind=" << kind_name(value->kind()) + << " display=\"" << MmsDataCodec::to_display_string(*value) << "\"" + << " rawHex=" << (raw_hex(*value).empty() ? "" : raw_hex(*value)); + if (value->unknown_tag_number()) { + std::cout << " unknownTag=" << *value->unknown_tag_number(); + } + if (numeric) { + std::cout << " numeric=" << *numeric; + } + std::cout << " interpreted=" << ctl_model_name(numeric) << '\n'; + std::cout << "CTLMODEL_RAW_RESULT readOnly=true mmsControlWriteCount=0\n"; + std::cout << "PCAP_HINT filter=\"tcp.port == " << endpoint.port + << " && ip.addr == " << endpoint.host << "\"\n"; + + live_session.disconnect(); + return 0; + } catch (const std::exception& exception) { + std::cerr << "ctlModel raw probe failed: " << exception.what() << '\n'; + return 1; + } +} diff --git a/tools/control_interop_probe.cpp b/tools/control_interop_probe.cpp new file mode 100644 index 000000000..d0b7a6983 --- /dev/null +++ b/tools/control_interop_probe.cpp @@ -0,0 +1,729 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/control_session.hpp" +#include "ariec61850/mms/live_discovery.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; +using namespace ar::iec61850::control; +using namespace ar::iec61850::mms; + +constexpr std::string_view kArmToken{"IEC61850-LAB-CONTROL"}; + +struct Options final { + MmsEndpoint endpoint{}; + std::string object_reference; + std::string action{"discover"}; + std::string value; + std::string value_kind; + std::string origin_identifier{"ARSTACK-C5"}; + OriginCategory origin_category{OriginCategory::station_control}; + std::chrono::milliseconds timeout{5'000}; + std::optional termination_timeout; + bool test{}; + bool interlock_check{true}; + bool synchro_check{true}; + bool auto_select{true}; + bool armed{}; + std::string evidence_path; + bool self_test{}; +}; + +struct LabAuthorization final { + bool armed{}; +}; + +[[nodiscard]] bool authorize_lab_control( + void* context, + ControlAction, + const ControlObjectReference&, + const ControlClientIdentity&, + const ControlSequenceView&) noexcept { + const auto* authorization = static_cast(context); + return authorization != nullptr && authorization->armed; +} + +[[nodiscard]] std::string lower(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](const char ch) { + return static_cast(std::tolower(static_cast(ch))); + }); + return value; +} + +[[nodiscard]] std::uint16_t parse_port(const std::string& value) { + std::size_t consumed{}; + const auto parsed = std::stoul(value, &consumed, 10); + if (consumed != value.size() || parsed == 0UL || parsed > 65'535UL) { + throw std::invalid_argument("MMS TCP port must be in the range 1..65535."); + } + return static_cast(parsed); +} + +[[nodiscard]] std::chrono::milliseconds parse_timeout( + const std::string& option, + const std::string& value) { + std::size_t consumed{}; + const auto parsed = std::stoull(value, &consumed, 10); + if (consumed != value.size() || parsed == 0ULL || + parsed > static_cast(std::numeric_limits::max())) { + throw std::invalid_argument(option + " requires a positive millisecond value."); + } + return std::chrono::milliseconds{static_cast(parsed)}; +} + +[[nodiscard]] bool parse_switch(const std::string& option, const std::string& value) { + const auto normalized = lower(value); + if (normalized == "on" || normalized == "true" || normalized == "1") { + return true; + } + if (normalized == "off" || normalized == "false" || normalized == "0") { + return false; + } + throw std::invalid_argument(option + " requires on/off."); +} + +[[nodiscard]] OriginCategory parse_origin_category(const std::string& value) { + const auto normalized = lower(value); + if (normalized == "bay") return OriginCategory::bay_control; + if (normalized == "station") return OriginCategory::station_control; + if (normalized == "remote") return OriginCategory::remote_control; + if (normalized == "automatic-bay") return OriginCategory::automatic_bay; + if (normalized == "automatic-station") return OriginCategory::automatic_station; + if (normalized == "automatic-remote") return OriginCategory::automatic_remote; + if (normalized == "maintenance") return OriginCategory::maintenance; + if (normalized == "process") return OriginCategory::process; + throw std::invalid_argument( + "--origin-category requires bay, station, remote, automatic-bay, " + "automatic-station, automatic-remote, maintenance, or process."); +} + +[[nodiscard]] const char* model_name(const ControlModel model) noexcept { + switch (model) { + case ControlModel::status_only: return "status-only"; + case ControlModel::direct_normal: return "direct-normal"; + case ControlModel::select_before_operate_normal: return "sbo-normal"; + case ControlModel::direct_enhanced: return "direct-enhanced"; + case ControlModel::select_before_operate_enhanced: return "sbo-enhanced"; + case ControlModel::unknown: return "unknown"; + } + return "unknown"; +} + +[[nodiscard]] const char* completion_name(const ControlCompletionState state) noexcept { + switch (state) { + case ControlCompletionState::accepted: return "accepted"; + case ControlCompletionState::positive_termination: return "positive-termination"; + case ControlCompletionState::negative_termination: return "negative-termination"; + case ControlCompletionState::rejected: return "rejected"; + case ControlCompletionState::unsupported: return "unsupported"; + case ControlCompletionState::timed_out: return "timed-out"; + case ControlCompletionState::association_lost: return "association-lost"; + case ControlCompletionState::cancelled: return "cancelled"; + } + return "unknown"; +} + +[[nodiscard]] const char* action_name(const ControlAction action) noexcept { + switch (action) { + case ControlAction::select: return "select"; + case ControlAction::select_with_value: return "select-with-value"; + case ControlAction::operate: return "operate"; + case ControlAction::cancel: return "cancel"; + } + return "unknown"; +} + +[[nodiscard]] const char* add_cause_name(const AddCause cause) noexcept { + switch (cause) { + case AddCause::unknown: return "unknown"; + case AddCause::not_supported: return "not-supported"; + case AddCause::blocked_by_switching_hierarchy: return "blocked-by-switching-hierarchy"; + case AddCause::select_failed: return "select-failed"; + case AddCause::invalid_position: return "invalid-position"; + case AddCause::position_reached: return "position-reached"; + case AddCause::parameter_change_in_execution: return "parameter-change-in-execution"; + case AddCause::step_limit: return "step-limit"; + case AddCause::blocked_by_mode: return "blocked-by-mode"; + case AddCause::blocked_by_process: return "blocked-by-process"; + case AddCause::blocked_by_interlocking: return "blocked-by-interlocking"; + case AddCause::blocked_by_synchrocheck: return "blocked-by-synchrocheck"; + case AddCause::command_already_in_execution: return "command-already-in-execution"; + case AddCause::blocked_by_health: return "blocked-by-health"; + case AddCause::one_of_n_control: return "one-of-n-control"; + case AddCause::abortion_by_cancel: return "abortion-by-cancel"; + case AddCause::time_limit_over: return "time-limit-over"; + case AddCause::abortion_by_trip: return "abortion-by-trip"; + case AddCause::object_not_selected: return "object-not-selected"; + case AddCause::object_already_selected: return "object-already-selected"; + case AddCause::no_access_authority: return "no-access-authority"; + case AddCause::ended_with_overshoot: return "ended-with-overshoot"; + case AddCause::abortion_due_to_deviation: return "abortion-due-to-deviation"; + case AddCause::abortion_by_communication_loss: return "abortion-by-communication-loss"; + case AddCause::abortion_by_command: return "abortion-by-command"; + case AddCause::none: return "none"; + case AddCause::inconsistent_parameters: return "inconsistent-parameters"; + case AddCause::locked_by_other_client: return "locked-by-other-client"; + } + return "unknown"; +} + +[[nodiscard]] std::string json_escape(const std::string& value) { + std::ostringstream output; + for (const auto ch : value) { + switch (ch) { + case '\\': output << "\\\\"; break; + case '"': output << "\\\""; break; + case '\n': output << "\\n"; break; + case '\r': output << "\\r"; break; + case '\t': output << "\\t"; break; + default: + if (static_cast(ch) < 0x20U) { + output << "\\u" << std::hex << std::setw(4) << std::setfill('0') + << static_cast(static_cast(ch)) + << std::dec; + } else { + output << ch; + } + } + } + return output.str(); +} + +[[nodiscard]] std::string hex_bytes(const std::vector& bytes) { + std::ostringstream output; + output << std::uppercase << std::hex << std::setfill('0'); + for (const auto byte : bytes) { + output << std::setw(2) << static_cast(byte); + } + return output.str(); +} + +[[nodiscard]] std::string value_text(const MmsDataValue& value) { + switch (value.kind()) { + case MmsDataKind::boolean: + return std::get(value.value()) ? "true" : "false"; + case MmsDataKind::integer: + return std::to_string(std::get(value.value())); + case MmsDataKind::unsigned_integer: + return std::to_string(std::get(value.value())); + case MmsDataKind::floating_point: { + std::ostringstream output; + if (const auto* float_value = std::get_if(&value.value())) { + output << *float_value; + } else if (const auto* double_value = std::get_if(&value.value())) { + output << *double_value; + } + return output.str(); + } + case MmsDataKind::visible_string: + case MmsDataKind::mms_string: + return std::get(value.value()); + case MmsDataKind::array: + case MmsDataKind::structure: { + std::ostringstream output; + output << '['; + for (std::size_t index = 0U; index < value.children().size(); ++index) { + if (index != 0U) output << ','; + output << value_text(value.children()[index]); + } + output << ']'; + return output.str(); + } + case MmsDataKind::bit_string: + case MmsDataKind::octet_string: + case MmsDataKind::binary_time: + case MmsDataKind::bcd: + case MmsDataKind::boolean_array: + case MmsDataKind::object_id: + case MmsDataKind::unknown: + return "0x" + hex_bytes(value.raw_value()); + case MmsDataKind::utc_time: + return ""; + } + return ""; +} + +class RecordingControlTransport final : public ControlTransport { +public: + explicit RecordingControlTransport(ControlTransport& inner) noexcept : inner_{inner} {} + + [[nodiscard]] bool associated() const noexcept override { return inner_.associated(); } + [[nodiscard]] std::uint64_t association_id() const noexcept override { + return inner_.association_id(); + } + + [[nodiscard]] std::optional read( + const MmsObjectName& object, + std::stop_token stop_token = {}) override { + reads.push_back(object.reference()); + return inner_.read(object, stop_token); + } + + [[nodiscard]] std::optional variable_specification( + const MmsObjectName& object, + std::stop_token stop_token = {}) override { + specifications.push_back(object.reference()); + return inner_.variable_specification(object, stop_token); + } + + [[nodiscard]] std::vector domain_variable_names( + const std::string& domain, + std::stop_token stop_token = {}) override { + name_lists.push_back(domain); + return inner_.domain_variable_names(domain, stop_token); + } + + [[nodiscard]] ControlTransportWriteResult write( + const MmsObjectName& object, + MmsDataValue value, + std::stop_token stop_token = {}) override { + writes.push_back(object.reference()); + return inner_.write(object, std::move(value), stop_token); + } + + void clear_information_reports() override { + ++report_drains; + inner_.clear_information_reports(); + } + + [[nodiscard]] bool wait_information_report( + const std::chrono::milliseconds timeout, + MmsInformationReport& report, + std::stop_token stop_token = {}) override { + ++report_waits; + const auto received = inner_.wait_information_report(timeout, report, stop_token); + if (received) ++reports_received; + return received; + } + + std::vector reads; + std::vector specifications; + std::vector name_lists; + std::vector writes; + std::size_t report_drains{}; + std::size_t report_waits{}; + std::size_t reports_received{}; + +private: + ControlTransport& inner_; +}; + +[[nodiscard]] ControlValue parse_control_value( + const std::string& raw_kind, + const std::string& raw_value, + const std::string& cdc) { + auto kind = lower(raw_kind); + if (kind.empty()) { + const auto normalized_cdc = lower(cdc); + if (normalized_cdc == "spc") kind = "bool"; + else if (normalized_cdc == "dpc") kind = "dpc"; + else if (normalized_cdc == "apc") kind = "float"; + else if (normalized_cdc == "bsc") kind = "step"; + else if (normalized_cdc == "inc/isc") kind = "int"; + else throw std::invalid_argument( + "Cannot infer --value-kind for vendor-specific CDC; specify it explicitly."); + } + + if (kind == "bool" || kind == "boolean") { + return ControlValue::boolean(parse_switch("--value", raw_value)); + } + if (kind == "dpc" || kind == "double-point") { + const auto value = lower(raw_value); + if (value == "off" || value == "1") return ControlValue::double_point(DoublePointValue::off); + if (value == "on" || value == "2") return ControlValue::double_point(DoublePointValue::on); + if (value == "intermediate" || value == "0") return ControlValue::double_point(DoublePointValue::intermediate); + if (value == "bad" || value == "3") return ControlValue::double_point(DoublePointValue::bad); + throw std::invalid_argument("DPC --value requires off, on, intermediate, or bad."); + } + if (kind == "int" || kind == "integer") { + std::size_t consumed{}; + const auto parsed = std::stoll(raw_value, &consumed, 0); + if (consumed != raw_value.size()) throw std::invalid_argument("Invalid signed integer --value."); + return ControlValue::integer(static_cast(parsed)); + } + if (kind == "uint" || kind == "unsigned") { + std::size_t consumed{}; + const auto parsed = std::stoull(raw_value, &consumed, 0); + if (consumed != raw_value.size()) throw std::invalid_argument("Invalid unsigned integer --value."); + return ControlValue::unsigned_integer(static_cast(parsed)); + } + if (kind == "float" || kind == "double") { + std::size_t consumed{}; + const auto parsed = std::stod(raw_value, &consumed); + if (consumed != raw_value.size()) throw std::invalid_argument("Invalid floating-point --value."); + return ControlValue::floating_point(parsed); + } + if (kind == "step") { + const auto colon = raw_value.find(':'); + const auto position_text = raw_value.substr(0U, colon); + std::size_t consumed{}; + const auto parsed = std::stoll(position_text, &consumed, 0); + if (consumed != position_text.size()) throw std::invalid_argument("Invalid step position --value."); + bool transient{}; + if (colon != std::string::npos) { + transient = parse_switch("step transient", raw_value.substr(colon + 1U)); + } + return ControlValue::step_position({static_cast(parsed), transient}); + } + throw std::invalid_argument( + "--value-kind requires bool, dpc, int, uint, float, or step."); +} + +[[nodiscard]] ControlActionResult execute_action( + const Options& options, + const ControlObjectDescriptor& descriptor, + ControlObjectSession& session, + const ControlRequest& request) { + if (options.action == "operate") { + return session.operate(request); + } + + if (options.action == "select-operate" || options.action == "select-cancel") { + if (!descriptor.requires_select()) { + throw std::invalid_argument( + options.action + " requires an SBO control model discovered from the IED."); + } + ControlActionResult selected; + if (descriptor.model == ControlModel::select_before_operate_enhanced) { + selected = session.select_with_value(request); + } else { + selected = session.select(request); + } + if (!selected.success()) return selected; + if (options.action == "select-cancel") return session.cancel(); + + auto operate_request = request; + operate_request.auto_select = false; + return session.operate(operate_request); + } + + throw std::invalid_argument( + "--action requires discover, operate, select-operate, or select-cancel."); +} + +void print_descriptor(const ControlObjectDescriptor& descriptor) { + std::cout << "CONTROL_DESCRIPTOR object=" << descriptor.object.domain << '/' + << descriptor.object.logical_node << '.' << descriptor.object.data_object_path + << " ctlModel=" << model_name(descriptor.model) + << " cdc=" << descriptor.cdc + << " requiresSelect=" << (descriptor.requires_select() ? "true" : "false") + << " enhanced=" << (descriptor.enhanced() ? "true" : "false") + << " sboTimeoutMs=" << descriptor.sbo_timeout.count() + << " operTimeoutMs=" << descriptor.operate_timeout.count() + << " commandTermination=" + << (descriptor.supports_command_termination ? "true" : "false") + << '\n'; + if (descriptor.status_object) { + std::cout << "STATUS_OBJECT " << descriptor.status_object->reference() + << " fc=" << descriptor.status_functional_constraint << '\n'; + } + for (const auto& evidence : descriptor.discovery_evidence) { + std::cout << "DISCOVERY_EVIDENCE " << evidence << '\n'; + } +} + +void write_evidence( + const Options& options, + const ControlObjectDescriptor& descriptor, + const RecordingControlTransport& transport, + const std::optional& status_before, + const std::optional& status_after, + const std::optional& result, + const MmsAssociationRuntime& association) { + if (options.evidence_path.empty()) return; + + std::ofstream output{options.evidence_path, std::ios::binary | std::ios::trunc}; + if (!output) { + throw std::runtime_error("Cannot open evidence file: " + options.evidence_path); + } + + output << "{\n" + << " \"schema\": \"arstack61850-control-interop-v1\",\n" + << " \"endpoint\": \"" << json_escape(options.endpoint.host) << ':' + << options.endpoint.port << "\",\n" + << " \"object\": \"" << json_escape(options.object_reference) << "\",\n" + << " \"action\": \"" << json_escape(options.action) << "\",\n" + << " \"armed\": " << (options.armed ? "true" : "false") << ",\n" + << " \"ctlModel\": \"" << model_name(descriptor.model) << "\",\n" + << " \"cdc\": \"" << json_escape(descriptor.cdc) << "\",\n" + << " \"associationProfile\": \"" + << json_escape(association.active_association_profile()) << "\",\n" + << " \"statusBefore\": "; + if (status_before) output << '"' << json_escape(*status_before) << '"'; + else output << "null"; + output << ",\n \"statusAfter\": "; + if (status_after) output << '"' << json_escape(*status_after) << '"'; + else output << "null"; + output << ",\n \"mmsControlWriteCount\": " << transport.writes.size() << ",\n" + << " \"mmsControlWrites\": ["; + for (std::size_t index = 0U; index < transport.writes.size(); ++index) { + if (index != 0U) output << ','; + output << "\"" << json_escape(transport.writes[index]) << "\""; + } + output << "],\n" + << " \"readCount\": " << transport.reads.size() << ",\n" + << " \"gvaaCount\": " << transport.specifications.size() << ",\n" + << " \"reportDrainCount\": " << transport.report_drains << ",\n" + << " \"reportWaitCount\": " << transport.report_waits << ",\n" + << " \"informationReportsReceived\": " << transport.reports_received << ",\n" + << " \"associationEventCount\": " << association.events().size(); + if (result) { + output << ",\n \"result\": {\n" + << " \"action\": \"" << action_name(result->action) << "\",\n" + << " \"completion\": \"" << completion_name(result->completion) << "\",\n" + << " \"requestAccepted\": " + << (result->request_accepted ? "true" : "false") << ",\n" + << " \"commandTerminationReceived\": " + << (result->command_termination_received ? "true" : "false") << ",\n" + << " \"positiveTermination\": " + << (result->positive_termination ? "true" : "false") << ",\n" + << " \"controlNumber\": " << static_cast(result->control_number) << ",\n" + << " \"rawControlError\": " << result->raw_control_error << ",\n" + << " \"rawAddCause\": " << result->raw_add_cause << ",\n" + << " \"addCause\": \"" << add_cause_name(result->add_cause) << "\",\n" + << " \"message\": \"" << json_escape(result->message) << "\"\n" + << " }"; + } + output << "\n}\n"; +} + +void print_usage() { + std::cout + << "Usage:\n" + << " ariec61850_control_interop_probe [port] --object LD/LN.DO [options]\n\n" + << "Read-only default:\n" + << " No --action means discover only. Discovery reads ctlModel, exact live GVAA,\n" + << " CF timeouts when exposed, and the best matching ST/MX status value.\n\n" + << "Write actions (LAB ONLY):\n" + << " --action operate|select-operate|select-cancel\n" + << " --value VALUE Required for a write action.\n" + << " --value-kind KIND bool|dpc|int|uint|float|step; inferred from CDC when possible.\n" + << " --arm IEC61850-LAB-CONTROL Mandatory exact token before any control Write.\n\n" + << "Control sequence options:\n" + << " --origin TEXT Origin identifier (default ARSTACK-C5, max 64 bytes).\n" + << " --origin-category CAT bay|station|remote|automatic-bay|automatic-station|\n" + << " automatic-remote|maintenance|process.\n" + << " --test on|off IEC 61850 Test flag (default off).\n" + << " --interlock-check on|off Check bit 1 (default on).\n" + << " --synchro-check on|off Check bit 0 (default on).\n" + << " --auto-select on|off Operate may select automatically (default on).\n" + << " --termination-timeout-ms N Override enhanced CommandTermination wait.\n" + << " --timeout-ms N MMS connect/request timeout (default 5000).\n" + << " --evidence FILE Write machine-readable JSON evidence.\n" + << " --self-test Offline harness safety self-test; no network.\n\n" + << "Safety: this tool never retries a command automatically. For a live write, use an\n" + << "isolated/non-operational laboratory IED and capture tcp.port==102 in Wireshark.\n"; +} + +[[nodiscard]] Options parse_options(const int argc, char** argv) { + Options options; + options.endpoint.port = 102U; + + if (argc == 2 && std::string_view{argv[1]} == "--self-test") { + options.self_test = true; + return options; + } + if (argc < 2 || std::string_view{argv[1]} == "--help" || + std::string_view{argv[1]} == "-h") { + return options; + } + + options.endpoint.host = argv[1]; + int argument = 2; + if (argument < argc && std::string_view{argv[argument]}.find("--") != 0U) { + options.endpoint.port = parse_port(argv[argument]); + ++argument; + } + + while (argument < argc) { + const std::string option = argv[argument++]; + if (option == "--help" || option == "-h") return Options{}; + if (argument >= argc) throw std::invalid_argument(option + " requires a value."); + const std::string value = argv[argument++]; + if (option == "--object") options.object_reference = value; + else if (option == "--action") options.action = lower(value); + else if (option == "--value") options.value = value; + else if (option == "--value-kind") options.value_kind = lower(value); + else if (option == "--origin") options.origin_identifier = value; + else if (option == "--origin-category") options.origin_category = parse_origin_category(value); + else if (option == "--timeout-ms") options.timeout = parse_timeout(option, value); + else if (option == "--termination-timeout-ms") { + options.termination_timeout = parse_timeout(option, value); + } else if (option == "--test") options.test = parse_switch(option, value); + else if (option == "--interlock-check") options.interlock_check = parse_switch(option, value); + else if (option == "--synchro-check") options.synchro_check = parse_switch(option, value); + else if (option == "--auto-select") options.auto_select = parse_switch(option, value); + else if (option == "--arm") options.armed = value == kArmToken; + else if (option == "--evidence") options.evidence_path = value; + else throw std::invalid_argument("Unknown option: " + option); + } + return options; +} + +void validate_options(const Options& options) { + if (options.endpoint.host.empty()) throw std::invalid_argument("Missing host."); + if (options.object_reference.empty()) throw std::invalid_argument("--object is required."); + if (options.origin_identifier.size() > 64U) { + throw std::invalid_argument("--origin must not exceed 64 bytes."); + } + if (options.action != "discover" && options.action != "operate" && + options.action != "select-operate" && options.action != "select-cancel") { + throw std::invalid_argument( + "--action requires discover, operate, select-operate, or select-cancel."); + } + if (options.action != "discover") { + if (!options.armed) { + throw std::invalid_argument( + "Control Write is locked. Add exact --arm IEC61850-LAB-CONTROL only for an isolated lab IED."); + } + if (options.value.empty()) throw std::invalid_argument("--value is required for a control action."); + } +} + +int run_self_test() { + Options read_only; + read_only.endpoint.host = "127.0.0.1"; + read_only.object_reference = "LD0/CSWI1.Pos"; + validate_options(read_only); + + auto blocked = read_only; + blocked.action = "operate"; + blocked.value = "on"; + bool rejected{}; + try { + validate_options(blocked); + } catch (const std::invalid_argument&) { + rejected = true; + } + if (!rejected) { + std::cerr << "SELF_TEST FAIL: unarmed control action was not rejected.\n"; + return 1; + } + + blocked.armed = true; + validate_options(blocked); + const auto dpc = parse_control_value("dpc", "on", "DPC"); + if (dpc.kind() != ControlValueKind::double_point) { + std::cerr << "SELF_TEST FAIL: DPC parsing mismatch.\n"; + return 1; + } + + std::cout << "CONTROL_INTEROP_SELF_TEST PASS readOnlyDefault=true armGate=true noAutoRetry=true\n"; + return 0; +} + +} // namespace + +int main(int argc, char** argv) { + try { + const auto options = parse_options(argc, argv); + if (options.self_test) return run_self_test(); + if (options.endpoint.host.empty()) { + print_usage(); + return argc > 1 ? 0 : 2; + } + validate_options(options); + + MmsAssociationOptions association_options; + association_options.connect_timeout = options.timeout; + association_options.request_timeout = options.timeout; + MmsTcpLiveDiscoverySession live_session{{}, association_options}; + live_session.connect(options.endpoint); + + MmsAssociationControlTransport association_transport{live_session.association()}; + RecordingControlTransport transport{association_transport}; + const auto descriptor = ControlDescriptorDiscovery::discover( + transport, options.object_reference); + print_descriptor(descriptor); + + std::optional status_before; + if (descriptor.status_object) { + if (const auto value = transport.read(*descriptor.status_object)) { + status_before = value_text(*value); + std::cout << "STATUS_BEFORE " << *status_before << '\n'; + } + } + + if (options.action == "discover") { + std::cout << "CONTROL_INTEROP_RESULT mode=read-only writes=0 status=DISCOVERY_PASS\n"; + write_evidence( + options, descriptor, transport, status_before, std::nullopt, + std::nullopt, live_session.association()); + live_session.disconnect(); + return 0; + } + + LabAuthorization authorization{options.armed}; + ControlSessionOptions session_options; + session_options.guarded_policy.authorization_context = &authorization; + session_options.guarded_policy.authorize = authorize_lab_control; + ControlObjectSession control_session{transport, descriptor, session_options}; + + ControlRequest request; + request.control_value = parse_control_value( + options.value_kind, options.value, descriptor.cdc); + request.origin_category = options.origin_category; + request.origin_identifier.assign( + options.origin_identifier.begin(), options.origin_identifier.end()); + request.test = options.test; + request.interlock_check = options.interlock_check; + request.synchro_check = options.synchro_check; + request.auto_select = options.auto_select; + request.command_termination_timeout = options.termination_timeout; + + const auto result = execute_action(options, descriptor, control_session, request); + + std::optional status_after; + if (descriptor.status_object && transport.associated()) { + if (const auto value = transport.read(*descriptor.status_object)) { + status_after = value_text(*value); + std::cout << "STATUS_AFTER " << *status_after << '\n'; + } + } + + std::cout << "CONTROL_RESULT action=" << action_name(result.action) + << " completion=" << completion_name(result.completion) + << " accepted=" << (result.request_accepted ? "true" : "false") + << " termination=" + << (result.command_termination_received ? "true" : "false") + << " ctlNum=" << static_cast(result.control_number) + << " addCause=" << add_cause_name(result.add_cause) + << " rawAddCause=" << result.raw_add_cause + << " message=\"" << result.message << "\"\n"; + + std::cout << "NO_RETRY_EVIDENCE controlWrites=" << transport.writes.size(); + for (const auto& write : transport.writes) { + std::cout << " write=" << write; + } + std::cout << '\n'; + std::cout << "PCAP_HINT filter=\"tcp.port == " << options.endpoint.port + << " && ip.addr == " << options.endpoint.host << "\"\n"; + + write_evidence( + options, descriptor, transport, status_before, status_after, + result, live_session.association()); + live_session.disconnect(); + return result.success() ? 0 : 4; + } catch (const std::exception& exception) { + std::cerr << "Control interoperability probe failed: " << exception.what() << '\n'; + return 1; + } +} diff --git a/tools/control_interop_probe/CMakeLists.txt b/tools/control_interop_probe/CMakeLists.txt new file mode 100644 index 000000000..86772d31e --- /dev/null +++ b/tools/control_interop_probe/CMakeLists.txt @@ -0,0 +1,67 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARIEC61850ControlInteropProbe + VERSION 0.1.0 + DESCRIPTION "Guarded IEC 61850 live control interoperability harness" + LANGUAGES CXX) + +set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") +set(ARIEC61850_BUILD_TOOLS OFF CACHE BOOL "" FORCE) +set(ARIEC61850_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(ARIEC61850_BUILD_FUZZERS OFF CACHE BOOL "" FORCE) + +add_subdirectory(${ARIEC61850_ROOT} ariec61850-core) + +add_executable(ariec61850_control_interop_probe + ${ARIEC61850_ROOT}/tools/control_interop_probe.cpp) +target_link_libraries(ariec61850_control_interop_probe PRIVATE ARIEC61850::core) +target_compile_features(ariec61850_control_interop_probe PRIVATE cxx_std_20) + +add_executable(ariec61850_control_inventory_probe + ${ARIEC61850_ROOT}/tools/control_inventory_probe.cpp) +target_link_libraries(ariec61850_control_inventory_probe PRIVATE ARIEC61850::core) +target_compile_features(ariec61850_control_inventory_probe PRIVATE cxx_std_20) + +add_executable(ariec61850_control_ctlmodel_probe + ${ARIEC61850_ROOT}/tools/control_ctlmodel_probe.cpp) +target_link_libraries(ariec61850_control_ctlmodel_probe PRIVATE ARIEC61850::core) +target_compile_features(ariec61850_control_ctlmodel_probe PRIVATE cxx_std_20) + +if(MSVC) + target_compile_options(ariec61850_control_interop_probe PRIVATE /W4 /WX /permissive-) + target_compile_options(ariec61850_control_inventory_probe PRIVATE /W4 /WX /permissive-) + target_compile_options(ariec61850_control_ctlmodel_probe PRIVATE /W4 /WX /permissive-) +else() + target_compile_options(ariec61850_control_interop_probe PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) + target_compile_options(ariec61850_control_inventory_probe PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) + target_compile_options(ariec61850_control_ctlmodel_probe PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) +endif() + +enable_testing() +add_test( + NAME ariec61850_control_interop_self_test + COMMAND ariec61850_control_interop_probe --self-test) +add_test( + NAME ariec61850_control_interop_help + COMMAND ariec61850_control_interop_probe --help) +add_test( + NAME ariec61850_control_inventory_self_test + COMMAND ariec61850_control_inventory_probe --self-test) +add_test( + NAME ariec61850_control_inventory_help + COMMAND ariec61850_control_inventory_probe --help) +add_test( + NAME ariec61850_control_ctlmodel_self_test + COMMAND ariec61850_control_ctlmodel_probe --self-test) +add_test( + NAME ariec61850_control_ctlmodel_help + COMMAND ariec61850_control_ctlmodel_probe --help) + +message(STATUS "arstack61850 C5 interop harness: read-only default=ON") +message(STATUS "arstack61850 C5 interop harness: explicit arm token required for Write=ON") +message(STATUS "arstack61850 C5 interop harness: automatic command retry=OFF") +message(STATUS "arstack61850 C5 control inventory companion: read-only only=ON") +message(STATUS "arstack61850 C5 raw ctlModel diagnostic companion: read-only only=ON") diff --git a/tools/control_inventory_probe.cpp b/tools/control_inventory_probe.cpp new file mode 100644 index 000000000..985bb8e41 --- /dev/null +++ b/tools/control_inventory_probe.cpp @@ -0,0 +1,344 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/control/control_session.hpp" +#include "ariec61850/mms/live_discovery.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; +using namespace ar::iec61850::control; +using namespace ar::iec61850::mms; + +struct Options final { + MmsEndpoint endpoint{}; + std::chrono::milliseconds timeout{5'000}; + std::string evidence_path; + bool self_test{}; +}; + +struct ControlCandidate final { + std::string domain; + std::string object_reference; + std::string ctl_model_item; +}; + +struct CandidateEvidence final { + ControlCandidate candidate; + bool operationally_ready{}; + std::string ctl_model; + std::string cdc; + std::string status_object; + std::string diagnostic; +}; + +[[nodiscard]] std::string lower_ascii(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](const char ch) { + return static_cast(std::tolower(static_cast(ch))); + }); + return value; +} + +[[nodiscard]] std::vector split_dollar(const std::string& value) { + std::vector parts; + std::size_t begin{}; + while (begin <= value.size()) { + const auto end = value.find('$', begin); + parts.push_back(value.substr( + begin, end == std::string::npos ? std::string::npos : end - begin)); + if (end == std::string::npos) break; + begin = end + 1U; + } + return parts; +} + +[[nodiscard]] std::vector extract_control_candidates( + const MmsDiscoverySnapshot& snapshot) { + std::vector candidates; + std::set> seen; + + for (const auto& [domain, variables] : snapshot.domain_variables) { + for (const auto& item : variables) { + const auto parts = split_dollar(item); + if (parts.size() < 4U || parts.front().empty()) continue; + if (lower_ascii(parts[1]) != "cf" || + lower_ascii(parts.back()) != "ctlmodel") { + continue; + } + + std::string data_object_path; + for (std::size_t index = 2U; index + 1U < parts.size(); ++index) { + if (parts[index].empty()) { + data_object_path.clear(); + break; + } + if (!data_object_path.empty()) data_object_path.push_back('.'); + data_object_path += parts[index]; + } + if (data_object_path.empty()) continue; + + ControlCandidate candidate; + candidate.domain = domain; + candidate.object_reference = domain + "/" + parts.front() + "." + data_object_path; + candidate.ctl_model_item = item; + + const auto identity = lower_ascii(candidate.object_reference); + if (seen.insert(identity).second) { + candidates.push_back(std::move(candidate)); + } + } + } + + std::sort(candidates.begin(), candidates.end(), [](const auto& left, const auto& right) { + return left.object_reference < right.object_reference; + }); + return candidates; +} + +[[nodiscard]] const char* model_name(const ControlModel model) noexcept { + switch (model) { + case ControlModel::status_only: return "status-only"; + case ControlModel::direct_normal: return "direct-normal"; + case ControlModel::select_before_operate_normal: return "sbo-normal"; + case ControlModel::direct_enhanced: return "direct-enhanced"; + case ControlModel::select_before_operate_enhanced: return "sbo-enhanced"; + case ControlModel::unknown: return "unknown"; + } + return "unknown"; +} + +[[nodiscard]] std::uint16_t parse_port(const std::string& value) { + std::size_t consumed{}; + const auto parsed = std::stoul(value, &consumed, 10); + if (consumed != value.size() || parsed == 0UL || parsed > 65'535UL) { + throw std::invalid_argument("MMS TCP port must be in the range 1..65535."); + } + return static_cast(parsed); +} + +[[nodiscard]] std::chrono::milliseconds parse_timeout(const std::string& value) { + std::size_t consumed{}; + const auto parsed = std::stoull(value, &consumed, 10); + if (consumed != value.size() || parsed == 0ULL || + parsed > static_cast(std::numeric_limits::max())) { + throw std::invalid_argument("--timeout-ms requires a positive millisecond value."); + } + return std::chrono::milliseconds{static_cast(parsed)}; +} + +[[nodiscard]] std::string json_escape(const std::string& value) { + std::ostringstream output; + for (const auto ch : value) { + switch (ch) { + case '\\': output << "\\\\"; break; + case '"': output << "\\\""; break; + case '\n': output << "\\n"; break; + case '\r': output << "\\r"; break; + case '\t': output << "\\t"; break; + default: + if (static_cast(ch) < 0x20U) output << '?'; + else output << ch; + } + } + return output.str(); +} + +void print_usage() { + std::cout + << "Usage:\n" + << " ariec61850_control_inventory_probe [port] [options]\n\n" + << "Options:\n" + << " --timeout-ms N MMS connect/request timeout (default 5000).\n" + << " --evidence FILE Write JSON inventory evidence.\n" + << " --self-test Offline parser/safety self-test; no network.\n\n" + << "Safety:\n" + << " This executable is read-only. It has no control Write action and no arm token.\n" + << " It enumerates MMS domains/named variables, finds CF/.../ctlModel candidates,\n" + << " and validates candidates with Read/GVAA only.\n"; +} + +[[nodiscard]] Options parse_options(const int argc, char** argv) { + Options options; + options.endpoint.port = 102U; + if (argc == 2 && std::string_view{argv[1]} == "--self-test") { + options.self_test = true; + return options; + } + if (argc < 2 || std::string_view{argv[1]} == "--help" || + std::string_view{argv[1]} == "-h") { + return options; + } + + options.endpoint.host = argv[1]; + int argument = 2; + if (argument < argc && std::string_view{argv[argument]}.find("--") != 0U) { + options.endpoint.port = parse_port(argv[argument]); + ++argument; + } + while (argument < argc) { + const std::string option = argv[argument++]; + if (option == "--help" || option == "-h") return Options{}; + if (argument >= argc) throw std::invalid_argument(option + " requires a value."); + const std::string value = argv[argument++]; + if (option == "--timeout-ms") options.timeout = parse_timeout(value); + else if (option == "--evidence") options.evidence_path = value; + else throw std::invalid_argument("Unknown option: " + option); + } + return options; +} + +void write_evidence( + const Options& options, + const MmsLiveDiscoveryResult& discovery, + const std::vector& evidence) { + if (options.evidence_path.empty()) return; + std::ofstream output{options.evidence_path, std::ios::binary | std::ios::trunc}; + if (!output) { + throw std::runtime_error("Cannot open evidence file: " + options.evidence_path); + } + + std::size_t ready{}; + for (const auto& item : evidence) { + if (item.operationally_ready) ++ready; + } + + output << "{\n" + << " \"schema\": \"arstack61850-control-inventory-v1\",\n" + << " \"endpoint\": \"" << json_escape(options.endpoint.host) << ':' + << options.endpoint.port << "\",\n" + << " \"readOnly\": true,\n" + << " \"mmsControlWriteCount\": 0,\n" + << " \"domainCount\": " << discovery.domain_count() << ",\n" + << " \"variableCount\": " << discovery.variable_count() << ",\n" + << " \"candidateCount\": " << evidence.size() << ",\n" + << " \"operationallyReadyCount\": " << ready << ",\n" + << " \"candidates\": [\n"; + for (std::size_t index = 0U; index < evidence.size(); ++index) { + const auto& item = evidence[index]; + output << " {\"object\": \"" << json_escape(item.candidate.object_reference) + << "\", \"ctlModelItem\": \"" << json_escape(item.candidate.ctl_model_item) + << "\", \"operationallyReady\": " + << (item.operationally_ready ? "true" : "false") + << ", \"ctlModel\": \"" << json_escape(item.ctl_model) + << "\", \"cdc\": \"" << json_escape(item.cdc) + << "\", \"statusObject\": \"" << json_escape(item.status_object) + << "\", \"diagnostic\": \"" << json_escape(item.diagnostic) << "\"}"; + if (index + 1U != evidence.size()) output << ','; + output << '\n'; + } + output << " ]\n}\n"; +} + +int run_self_test() { + MmsDiscoverySnapshot snapshot; + snapshot.domain_variables["LD0"] = { + "LLN0$ST$Mod$stVal", + "CSWI1$CF$Pos$ctlModel", + "CSWI1$CO$Pos$Oper", + "CILO1$CF$EnaOpn$ctlModel", + "MMXU1$MX$A$phsA$cVal$mag$f", + }; + const auto candidates = extract_control_candidates(snapshot); + if (candidates.size() != 2U || + candidates[0].object_reference != "LD0/CILO1.EnaOpn" || + candidates[1].object_reference != "LD0/CSWI1.Pos") { + std::cerr << "CONTROL_INVENTORY_SELF_TEST FAIL candidate extraction mismatch.\n"; + return 1; + } + std::cout << "CONTROL_INVENTORY_SELF_TEST PASS readOnly=true controlWritePath=false candidates=2\n"; + return 0; +} + +} // namespace + +int main(int argc, char** argv) { + try { + const auto options = parse_options(argc, argv); + if (options.self_test) return run_self_test(); + if (options.endpoint.host.empty()) { + print_usage(); + return argc > 1 ? 0 : 2; + } + + MmsAssociationOptions association_options; + association_options.connect_timeout = options.timeout; + association_options.request_timeout = options.timeout; + MmsTcpLiveDiscoverySession live_session{{}, association_options}; + live_session.connect(options.endpoint); + + MmsLiveDiscoveryOptions discovery_options; + discovery_options.probe_variable_types = false; + discovery_options.read_data_set_directories = false; + discovery_options.probe_report_controls = false; + const auto discovery = live_session.discover(discovery_options); + + std::cout << "CONTROL_INVENTORY_ENDPOINT " << options.endpoint.host << ':' + << options.endpoint.port << '\n'; + std::cout << "MMS_INVENTORY domains=" << discovery.domain_count() + << " variables=" << discovery.variable_count() << '\n'; + + const auto candidates = extract_control_candidates(discovery.names); + MmsAssociationControlTransport transport{live_session.association()}; + std::vector evidence; + evidence.reserve(candidates.size()); + + for (const auto& candidate : candidates) { + CandidateEvidence item; + item.candidate = candidate; + try { + const auto descriptor = ControlDescriptorDiscovery::discover( + transport, candidate.object_reference); + item.operationally_ready = descriptor.operationally_ready(); + item.ctl_model = model_name(descriptor.model); + item.cdc = descriptor.cdc; + if (descriptor.status_object) item.status_object = descriptor.status_object->reference(); + std::cout << "CONTROL_READY object=" << candidate.object_reference + << " ctlModel=" << item.ctl_model + << " cdc=" << item.cdc + << " requiresSelect=" << (descriptor.requires_select() ? "true" : "false") + << " enhanced=" << (descriptor.enhanced() ? "true" : "false"); + if (!item.status_object.empty()) { + std::cout << " status=" << item.status_object; + } + std::cout << '\n'; + } catch (const std::exception& exception) { + item.diagnostic = exception.what(); + std::cout << "CONTROL_CANDIDATE object=" << candidate.object_reference + << " ctlModelItem=" << candidate.ctl_model_item + << " ready=false reason=\"" << item.diagnostic << "\"\n"; + } + evidence.push_back(std::move(item)); + } + + std::size_t ready{}; + for (const auto& item : evidence) { + if (item.operationally_ready) ++ready; + } + std::cout << "CONTROL_INVENTORY_RESULT readOnly=true mmsControlWriteCount=0" + << " candidates=" << evidence.size() + << " ready=" << ready << '\n'; + std::cout << "PCAP_HINT filter=\"tcp.port == " << options.endpoint.port + << " && ip.addr == " << options.endpoint.host << "\"\n"; + + write_evidence(options, discovery, evidence); + live_session.disconnect(); + return ready > 0U ? 0 : 3; + } catch (const std::exception& exception) { + std::cerr << "Control inventory probe failed: " << exception.what() << '\n'; + return 1; + } +}