diff --git a/.github/workflows/brcb-hard-profile-ci.yml b/.github/workflows/brcb-hard-profile-ci.yml new file mode 100644 index 000000000..5d3ea5cfa --- /dev/null +++ b/.github/workflows/brcb-hard-profile-ci.yml @@ -0,0 +1,82 @@ +name: BRCB Hard Profile CI + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + brcb-hard-profile: + name: BRCB Hard / ${{ matrix.compiler }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + compiler: [gcc, clang] + + steps: + - uses: actions/checkout@v4 + + - name: Select compiler + 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 BRCB hard profile + run: cmake -S embedded/brcb_hard_profile -B build-brcb-hard + + - name: Build BRCB hard profile + run: cmake --build build-brcb-hard --parallel + + - name: Run BRCB smokes + run: >- + ctest --test-dir build-brcb-hard + --output-on-failure + --output-junit brcb-ctest.xml + + - name: Prove exception runtime is absent + shell: bash + run: | + set -euo pipefail + for binary in \ + build-brcb-hard/ariec61850_hard_mms_brcb_smoke \ + build-brcb-hard/ariec61850_hard_mms_brcb_connection_smoke \ + build-brcb-hard/ariec61850_hard_mms_brcb_persistence_smoke; do + if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then + echo "exception runtime symbol found in $binary" >&2 + exit 1 + fi + done + echo 'NO_EXCEPTION_RUNTIME=PASS' + + - name: Report footprint + shell: bash + run: | + set -euo pipefail + { + echo "compiler=${{ matrix.compiler }}" + size \ + build-brcb-hard/ariec61850_hard_mms_brcb_smoke \ + build-brcb-hard/ariec61850_hard_mms_brcb_connection_smoke \ + build-brcb-hard/ariec61850_hard_mms_brcb_persistence_smoke + } | tee build-brcb-hard/brcb-footprint.txt + + - name: Upload BRCB evidence + uses: actions/upload-artifact@v4 + with: + name: brcb-hard-${{ matrix.compiler }}-${{ github.event.pull_request.head.sha || github.sha }} + if-no-files-found: error + retention-days: 14 + path: | + build-brcb-hard/brcb-ctest.xml + build-brcb-hard/brcb-footprint.txt diff --git a/.github/workflows/cpp-ci.yml b/.github/workflows/cpp-ci.yml index ccb9ee052..b0f205eac 100644 --- a/.github/workflows/cpp-ci.yml +++ b/.github/workflows/cpp-ci.yml @@ -66,3 +66,29 @@ jobs: python scripts/run-phase4c-physical-acceptance.py --help | Out-Null python -m py_compile scripts/run-phase4c-timeout-recovery.py python scripts/run-phase4c-timeout-recovery.py --help | Out-Null + - name: Build deterministic SV injector evidence bundle + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $out = 'sv-injector-artifact' + New-Item -ItemType Directory -Force -Path $out | Out-Null + $exe = 'build/Release/ariec61850_sv_injector.exe' + if (-not (Test-Path $exe)) { throw "Missing $exe" } + & $exe --capabilities | Out-File -Encoding utf8 "$out/capabilities.json" + & $exe --scenario normal --frames 8000 --pcap "$out/sv-loopback-normal-2s.pcap" --json | + Out-File -Encoding utf8 "$out/normal-2s-evidence.json" + if ($LASTEXITCODE -ne 0) { throw "Normal 8,000-frame injector evidence run failed: $LASTEXITCODE" } + & $exe --scenario protection-fault --json | + Out-File -Encoding utf8 "$out/protection-fault-evidence.json" + if ($LASTEXITCODE -ne 0) { throw "Protection-fault injector evidence run failed: $LASTEXITCODE" } + Copy-Item $exe "$out/ariec61850_sv_injector.exe" + Get-FileHash "$out/ariec61850_sv_injector.exe", "$out/sv-loopback-normal-2s.pcap", "$out/normal-2s-evidence.json", "$out/protection-fault-evidence.json", "$out/capabilities.json" -Algorithm SHA256 | + ForEach-Object { "{0} {1}" -f $_.Hash.ToLowerInvariant(), (Split-Path $_.Path -Leaf) } | + Out-File -Encoding ascii "$out/SHA256SUMS" + - name: Upload Windows SV injector + uses: actions/upload-artifact@v4 + with: + name: windows-sv-injector-${{ github.event.pull_request.head.sha || github.sha }} + if-no-files-found: error + retention-days: 14 + path: sv-injector-artifact/ diff --git a/.github/workflows/embedded-ci.yml b/.github/workflows/embedded-ci.yml index f3682334a..191a01fb7 100644 --- a/.github/workflows/embedded-ci.yml +++ b/.github/workflows/embedded-ci.yml @@ -97,12 +97,38 @@ jobs: - name: Build embedded profile run: cmake --build build-embedded --parallel - - name: Test embedded full-link smoke - run: ctest --test-dir build-embedded --output-on-failure + - name: Test embedded host simulation + run: >- + ctest --test-dir build-embedded + --output-on-failure + --output-junit embedded-ctest.xml + + - name: Independently verify exact SV PCAP wire image + shell: bash + run: | + set -euo pipefail + python3 tools/verify_sv_pcap.py \ + build-embedded/sv_trial_2s.pcap \ + --expected-frames 8000 \ + --report build-embedded/sv-pcap-qa.json + sha256sum build-embedded/sv_trial_2s.pcap - name: Report host-sim footprint shell: bash run: | if command -v size >/dev/null 2>&1; then size build-embedded/ariec61850_embedded_link_smoke || true + size build-embedded/ariec61850_embedded_sv_publisher_smoke || true + size build-embedded/ariec61850_embedded_sv_stream_pcap || true fi + + - name: Upload host-simulation QA evidence + uses: actions/upload-artifact@v4 + with: + name: embedded-host-sim-${{ matrix.compiler }}-${{ github.event.pull_request.head.sha || github.sha }} + if-no-files-found: error + retention-days: 14 + path: | + build-embedded/embedded-ctest.xml + build-embedded/sv_trial_2s.pcap + build-embedded/sv-pcap-qa.json diff --git a/.github/workflows/embedded-hard-profile-ci.yml b/.github/workflows/embedded-hard-profile-ci.yml new file mode 100644 index 000000000..9169db5d0 --- /dev/null +++ b/.github/workflows/embedded-hard-profile-ci.yml @@ -0,0 +1,108 @@ +name: Embedded Hard Profile CI + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + hard-embedded-profile: + name: Hard Embedded / ${{ matrix.compiler }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + compiler: [gcc, clang] + + steps: + - uses: actions/checkout@v4 + + - name: Select compiler + 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 hard profile + run: cmake -S embedded/hard_profile -B build-hard-embedded + + - name: Build hard profile + run: cmake --build build-hard-embedded --parallel + + - name: Run hard-profile smokes + run: >- + ctest --test-dir build-hard-embedded + --output-on-failure + --output-junit hard-embedded-ctest.xml + + - name: Prove exception runtime is absent + shell: bash + run: | + set -euo pipefail + for binary in \ + build-hard-embedded/ariec61850_hard_sv_smoke \ + build-hard-embedded/ariec61850_hard_goose_smoke \ + build-hard-embedded/ariec61850_hard_osi_transport_smoke \ + build-hard-embedded/ariec61850_hard_session_presentation_smoke \ + build-hard-embedded/ariec61850_hard_association_smoke \ + build-hard-embedded/ariec61850_hard_mms_pdu_smoke \ + build-hard-embedded/ariec61850_hard_mms_get_name_list_smoke \ + build-hard-embedded/ariec61850_hard_mms_static_model_smoke \ + build-hard-embedded/ariec61850_hard_mms_write_smoke \ + build-hard-embedded/ariec61850_hard_mms_dispatcher_smoke \ + build-hard-embedded/ariec61850_hard_mms_connection_runtime_smoke \ + build-hard-embedded/ariec61850_hard_mms_data_set_smoke \ + build-hard-embedded/ariec61850_hard_mms_information_report_smoke \ + build-hard-embedded/ariec61850_hard_mms_urcb_runtime_smoke \ + build-hard-embedded/ariec61850_hard_mms_urcb_objects_smoke \ + build-hard-embedded/ariec61850_hard_mms_report_connection_smoke; do + if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then + echo "exception runtime symbol found in $binary" >&2 + exit 1 + fi + done + echo 'NO_EXCEPTION_RUNTIME=PASS' + + - name: Report hard-profile footprint + shell: bash + run: | + set -euo pipefail + { + echo "compiler=${{ matrix.compiler }}" + size \ + build-hard-embedded/ariec61850_hard_sv_smoke \ + build-hard-embedded/ariec61850_hard_goose_smoke \ + build-hard-embedded/ariec61850_hard_osi_transport_smoke \ + build-hard-embedded/ariec61850_hard_session_presentation_smoke \ + build-hard-embedded/ariec61850_hard_association_smoke \ + build-hard-embedded/ariec61850_hard_mms_pdu_smoke \ + build-hard-embedded/ariec61850_hard_mms_get_name_list_smoke \ + build-hard-embedded/ariec61850_hard_mms_static_model_smoke \ + build-hard-embedded/ariec61850_hard_mms_write_smoke \ + build-hard-embedded/ariec61850_hard_mms_dispatcher_smoke \ + build-hard-embedded/ariec61850_hard_mms_connection_runtime_smoke \ + build-hard-embedded/ariec61850_hard_mms_data_set_smoke \ + build-hard-embedded/ariec61850_hard_mms_information_report_smoke \ + build-hard-embedded/ariec61850_hard_mms_urcb_runtime_smoke \ + build-hard-embedded/ariec61850_hard_mms_urcb_objects_smoke \ + build-hard-embedded/ariec61850_hard_mms_report_connection_smoke + } | tee build-hard-embedded/hard-embedded-footprint.txt + + - name: Upload hard-profile evidence + uses: actions/upload-artifact@v4 + with: + name: embedded-hard-profile-${{ matrix.compiler }}-${{ github.event.pull_request.head.sha || github.sha }} + if-no-files-found: error + retention-days: 14 + path: | + build-hard-embedded/hard-embedded-ctest.xml + build-hard-embedded/hard-embedded-footprint.txt diff --git a/.github/workflows/esp-idf-ci.yml b/.github/workflows/esp-idf-ci.yml new file mode 100644 index 000000000..d8573ec6f --- /dev/null +++ b/.github/workflows/esp-idf-ci.yml @@ -0,0 +1,262 @@ +name: ESP-IDF Compile CI + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + esp32p4-compile: + name: ESP32-P4 / ESP-IDF v6.0.2 + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build ESP32-P4 component smoke app + uses: espressif/esp-idf-ci-action@v1 + with: + esp_idf_version: v6.0.2 + target: esp32p4 + path: ports/esp_idf/smoke_app + + - name: Build ESP32-P4 BRCB flash probe firmware + uses: espressif/esp-idf-ci-action@v1 + with: + esp_idf_version: v6.0.2 + target: esp32p4 + path: ports/esp_idf/brcb_flash_probe_app + + - name: Build flashable ESP32-P4 SV trial firmware + uses: espressif/esp-idf-ci-action@v1 + with: + esp_idf_version: v6.0.2 + target: esp32p4 + path: ports/esp_idf/sv_trial_app + + - name: Verify BRCB flash probe outputs + shell: bash + run: | + set -euo pipefail + app=ports/esp_idf/brcb_flash_probe_app + build="$app/build" + test -s "$build/arstack61850_esp32p4_brcb_flash_probe.bin" + test -s "$build/arstack61850_esp32p4_brcb_flash_probe.elf" + test -s "$build/arstack61850_esp32p4_brcb_flash_probe.map" + test -s "$build/bootloader/bootloader.bin" + test -s "$build/partition_table/partition-table.bin" + test -s "$build/flasher_args.json" + test -s "$app/partitions.csv" + test -s "$app/sdkconfig.defaults" + test -s "$app/README.md" + grep -Eq '^brcb_state,[[:space:]]*0x40,[[:space:]]*0x01,[[:space:]]*,[[:space:]]*0x3000' "$app/partitions.csv" + grep -q 'CONFIG_PARTITION_TABLE_CUSTOM 1' "$build/config/sdkconfig.h" + python3 -m json.tool "$build/flasher_args.json" >/dev/null + python3 - <<'PY' + import json + from pathlib import Path + + app = Path('ports/esp_idf/brcb_flash_probe_app') + build = app / 'build' + manifest = json.loads((build / 'flasher_args.json').read_text(encoding='utf-8')) + chip = manifest.get('extra_esptool_args', {}).get('chip') + if chip != 'esp32p4': + raise SystemExit(f'expected flasher chip esp32p4, got {chip!r}') + raw = manifest.get('flash_files') + if not isinstance(raw, dict): + raise SystemExit('probe flasher manifest has no flash_files object') + flash_files = {int(str(k), 0): str(v) for k, v in raw.items()} + expected = { + 0x2000: 'bootloader/bootloader.bin', + 0x8000: 'partition_table/partition-table.bin', + 0x10000: 'arstack61850_esp32p4_brcb_flash_probe.bin', + } + if flash_files != expected: + raise SystemExit( + f'unexpected probe flash layout: expected {expected!r}, got {flash_files!r}') + print('ESP32P4/BRCB-FLASH-PROBE/CROSS-COMPILED') + PY + + - name: Verify flashable firmware outputs + shell: bash + run: | + set -euo pipefail + app=ports/esp_idf/sv_trial_app + build="$app/build" + qa="$app/ci-artifacts" + mkdir -p "$qa" + test -s "$build/arstack61850_esp32p4_sv_trial.bin" + test -s "$build/arstack61850_esp32p4_sv_trial.elf" + test -s "$build/arstack61850_esp32p4_sv_trial.map" + test -s "$build/bootloader/bootloader.bin" + test -s "$build/partition_table/partition-table.bin" + test -s "$build/flash_args" + test -s "$build/flasher_args.json" + test -s "$app/sdkconfig.defaults" + test -s "$app/partitions.csv" + test -s "$app/README.md" + grep -Eq '^brcb_state,[[:space:]]*0x40,[[:space:]]*0x01,[[:space:]]*,[[:space:]]*0x3000' "$app/partitions.csv" + grep -q 'CONFIG_PARTITION_TABLE_CUSTOM 1' "$build/config/sdkconfig.h" + python3 -m json.tool "$build/flasher_args.json" >/dev/null + python3 - <<'PY' + import json + from pathlib import Path + + app = Path('ports/esp_idf/sv_trial_app') + build = app / 'build' + qa = app / 'ci-artifacts' + qa.mkdir(parents=True, exist_ok=True) + + manifest = json.loads((build / 'flasher_args.json').read_text(encoding='utf-8')) + chip = manifest.get('extra_esptool_args', {}).get('chip') + if chip != 'esp32p4': + raise SystemExit(f'expected flasher chip esp32p4, got {chip!r}') + + expected = { + 0x2000: 'bootloader/bootloader.bin', + 0x8000: 'partition_table/partition-table.bin', + 0x10000: 'arstack61850_esp32p4_sv_trial.bin', + } + raw_flash_files = manifest.get('flash_files') + if not isinstance(raw_flash_files, dict): + raise SystemExit('flasher manifest has no flash_files object') + + try: + flash_files = { + int(str(offset), 0): str(relative) + for offset, relative in raw_flash_files.items() + } + except (TypeError, ValueError) as exc: + raise SystemExit(f'invalid flasher offset: {exc}') from exc + + if flash_files != expected: + pretty_actual = {hex(k): v for k, v in sorted(flash_files.items())} + pretty_expected = {hex(k): v for k, v in sorted(expected.items())} + raise SystemExit( + f'unexpected flash layout: expected {pretty_expected!r}, got {pretty_actual!r}') + + flash_evidence = {} + for offset, relative in sorted(expected.items()): + candidate = build / relative + if not candidate.is_file(): + raise SystemExit(f'flash file {relative!r} at {hex(offset)} is missing') + size = candidate.stat().st_size + if size <= 0: + raise SystemExit(f'flash file {relative!r} at {hex(offset)} is empty') + flash_evidence[hex(offset)] = {'path': relative, 'bytes': size} + + required = [ + build / 'arstack61850_esp32p4_sv_trial.bin', + build / 'arstack61850_esp32p4_sv_trial.elf', + build / 'arstack61850_esp32p4_sv_trial.map', + build / 'bootloader/bootloader.bin', + build / 'partition_table/partition-table.bin', + build / 'flash_args', + build / 'flasher_args.json', + app / 'sdkconfig.defaults', + app / 'partitions.csv', + app / 'README.md', + ] + inventory = [] + for candidate in required: + if not candidate.is_file() or candidate.stat().st_size <= 0: + raise SystemExit(f'required deployment file is missing or empty: {candidate}') + inventory.append(f'{candidate.relative_to(app)}\t{candidate.stat().st_size} bytes') + (qa / 'ARSTACK_BUILD_FILE_LIST.txt').write_text( + '\n'.join(inventory) + '\n', encoding='utf-8') + + evidence = { + 'status': 'FLASHABLE/READY', + 'target': chip, + 'flash_files': flash_evidence, + 'authoritative_manifest': 'build/flasher_args.json', + 'companion_flash_args': 'build/flash_args', + 'sdkconfig_defaults': 'sdkconfig.defaults', + 'brcb_partition_table': 'partitions.csv', + 'brcb_flash_measurement': 'READY_NOT_MEASURED', + } + (qa / 'ARSTACK_BUILD_PROFILE.json').write_text( + json.dumps(evidence, indent=2, sort_keys=True) + '\n', + encoding='utf-8') + print(json.dumps(evidence, indent=2, sort_keys=True)) + PY + ( + cd "$app" + sha256sum \ + build/arstack61850_esp32p4_sv_trial.bin \ + build/arstack61850_esp32p4_sv_trial.elf \ + build/bootloader/bootloader.bin \ + build/partition_table/partition-table.bin \ + build/flash_args \ + build/flasher_args.json \ + ci-artifacts/ARSTACK_BUILD_PROFILE.json \ + ci-artifacts/ARSTACK_BUILD_FILE_LIST.txt \ + sdkconfig.defaults \ + partitions.csv \ + README.md > ci-artifacts/SHA256SUMS + ) + echo "FLASHABLE/READY: ESP32-P4 flasher manifest, BRCB partition layout and firmware set verified." + + - name: Upload failed Gate-C diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: esp32p4-gate-c-diagnostics-${{ github.event.pull_request.head.sha || github.sha }} + if-no-files-found: warn + retention-days: 7 + path: | + ports/esp_idf/sv_trial_app/ci-artifacts/ARSTACK_BUILD_FILE_LIST.txt + ports/esp_idf/sv_trial_app/ci-artifacts/ARSTACK_BUILD_PROFILE.json + ports/esp_idf/sv_trial_app/ci-artifacts/SHA256SUMS + ports/esp_idf/sv_trial_app/build/flasher_args.json + ports/esp_idf/sv_trial_app/build/flash_args + ports/esp_idf/sv_trial_app/build/arstack61850_esp32p4_sv_trial.bin + ports/esp_idf/sv_trial_app/build/bootloader/bootloader.bin + ports/esp_idf/sv_trial_app/build/partition_table/partition-table.bin + ports/esp_idf/brcb_flash_probe_app/build/flasher_args.json + ports/esp_idf/brcb_flash_probe_app/build/arstack61850_esp32p4_brcb_flash_probe.bin + ports/esp_idf/brcb_flash_probe_app/build/partition_table/partition-table.bin + + - name: Upload ESP32-P4 BRCB flash probe firmware + uses: actions/upload-artifact@v4 + with: + name: esp32p4-brcb-flash-probe-${{ github.event.pull_request.head.sha || github.sha }} + if-no-files-found: error + retention-days: 14 + path: | + ports/esp_idf/brcb_flash_probe_app/build/arstack61850_esp32p4_brcb_flash_probe.bin + ports/esp_idf/brcb_flash_probe_app/build/arstack61850_esp32p4_brcb_flash_probe.elf + ports/esp_idf/brcb_flash_probe_app/build/arstack61850_esp32p4_brcb_flash_probe.map + ports/esp_idf/brcb_flash_probe_app/build/bootloader/bootloader.bin + ports/esp_idf/brcb_flash_probe_app/build/partition_table/partition-table.bin + ports/esp_idf/brcb_flash_probe_app/build/flash_args + ports/esp_idf/brcb_flash_probe_app/build/flasher_args.json + ports/esp_idf/brcb_flash_probe_app/sdkconfig.defaults + ports/esp_idf/brcb_flash_probe_app/partitions.csv + ports/esp_idf/brcb_flash_probe_app/README.md + + - name: Upload ESP32-P4 flashable firmware + uses: actions/upload-artifact@v4 + with: + name: esp32p4-sv-trial-firmware-${{ github.event.pull_request.head.sha || github.sha }} + if-no-files-found: error + retention-days: 14 + path: | + ports/esp_idf/sv_trial_app/build/arstack61850_esp32p4_sv_trial.bin + ports/esp_idf/sv_trial_app/build/arstack61850_esp32p4_sv_trial.elf + ports/esp_idf/sv_trial_app/build/arstack61850_esp32p4_sv_trial.map + ports/esp_idf/sv_trial_app/build/bootloader/bootloader.bin + ports/esp_idf/sv_trial_app/build/partition_table/partition-table.bin + ports/esp_idf/sv_trial_app/build/flash_args + ports/esp_idf/sv_trial_app/build/flasher_args.json + ports/esp_idf/sv_trial_app/ci-artifacts/ARSTACK_BUILD_PROFILE.json + ports/esp_idf/sv_trial_app/ci-artifacts/ARSTACK_BUILD_FILE_LIST.txt + ports/esp_idf/sv_trial_app/ci-artifacts/SHA256SUMS + ports/esp_idf/sv_trial_app/sdkconfig.defaults + ports/esp_idf/sv_trial_app/partitions.csv + ports/esp_idf/sv_trial_app/README.md diff --git a/.github/workflows/sv-device-control-ci.yml b/.github/workflows/sv-device-control-ci.yml new file mode 100644 index 000000000..70a50c335 --- /dev/null +++ b/.github/workflows/sv-device-control-ci.yml @@ -0,0 +1,71 @@ +name: SV Device Control CI + +on: + push: + branches: + - agent/esp32p4-sv-publisher-first-trial + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + name: Windows / MSVC + - os: ubuntu-24.04 + name: Linux / GCC portability + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - name: Configure + run: cmake -S tools/device_ctl -B build-device-ctl + + - name: Build + run: cmake --build build-device-ctl --config Release --parallel + + - name: CLI contract smoke (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $exe = '.\build-device-ctl\Release\ariec61850_sv_device_ctl.exe' + & $exe --help + $manual = & $exe --emit-request set-channel Ia rms 2500 + if ($manual -ne '{"command":"set-channel","channel":"Ia","rms":2500}') { throw "manual request mismatch: $manual" } + $ramp = & $exe --emit-request ramp-channel Va rms 9000 4000 + if ($ramp -ne '{"command":"ramp-channel","channel":"Va","rms":9000,"durationSamples":4000}') { throw "ramp request mismatch: $ramp" } + $state = & $exe --emit-request sequence-state-begin 800 linear + if ($state -ne '{"command":"sequence-state-begin","durationSamples":800,"transition":"linear"}') { throw "state request mismatch: $state" } + $edit = & $exe --emit-request sequence-set-channel Ic phase 120000 + if ($edit -ne '{"command":"sequence-set-channel","channel":"Ic","phaseMilliDeg":120000}') { throw "sequence edit mismatch: $edit" } + $commit = & $exe --emit-request sequence-commit + if ($commit -ne '{"command":"sequence-commit"}') { throw "sequence commit mismatch: $commit" } + + - name: CLI contract smoke (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + exe=./build-device-ctl/ariec61850_sv_device_ctl + "$exe" --help + test "$("$exe" --emit-request set-channel Ia rms 2500)" = '{"command":"set-channel","channel":"Ia","rms":2500}' + test "$("$exe" --emit-request ramp-channel Va rms 9000 4000)" = '{"command":"ramp-channel","channel":"Va","rms":9000,"durationSamples":4000}' + test "$("$exe" --emit-request sequence-state-begin 800 linear)" = '{"command":"sequence-state-begin","durationSamples":800,"transition":"linear"}' + test "$("$exe" --emit-request sequence-set-channel Ic phase 120000)" = '{"command":"sequence-set-channel","channel":"Ic","phaseMilliDeg":120000}' + test "$("$exe" --emit-request sequence-commit)" = '{"command":"sequence-commit"}' + + - name: Upload Windows device controller + if: runner.os == 'Windows' + uses: actions/upload-artifact@v4 + with: + name: windows-sv-device-control-${{ github.sha }} + path: build-device-ctl/Release/ariec61850_sv_device_ctl.exe + if-no-files-found: error diff --git a/.github/workflows/sv-replay-ci.yml b/.github/workflows/sv-replay-ci.yml new file mode 100644 index 000000000..1af00f5a3 --- /dev/null +++ b/.github/workflows/sv-replay-ci.yml @@ -0,0 +1,85 @@ +name: SV Replay CI + +on: + push: + branches: + - agent/esp32p4-sv-publisher-first-trial + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + replay-bundle: + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + name: Windows / MSVC + - os: ubuntu-24.04 + name: Linux / GCC + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - name: Configure + run: cmake -S tools/replay_prepare -B build-replay + + - name: Build + run: cmake --build build-replay --config Release --parallel + + - name: Prepare and validate replay bundle (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + .\build-replay\Release\ariec61850_sv_replay_prepare.exe tests\fixtures\comtrade\simple_fault.cfg build-replay\simple_fault.arsvr + .\build-replay\Release\ariec61850_sv_replay_live.exe --bundle build-replay\simple_fault.arsvr --validate + + - name: Prepare and validate replay bundle (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + ./build-replay/ariec61850_sv_replay_prepare tests/fixtures/comtrade/simple_fault.cfg build-replay/simple_fault.arsvr + ./build-replay/ariec61850_sv_replay_live --bundle build-replay/simple_fault.arsvr --validate + + - name: Independent replay bundle oracle + shell: python + run: | + import pathlib + import struct + + path = pathlib.Path('build-replay/simple_fault.arsvr') + data = path.read_bytes() + assert data[:8] == b'ARSVRPL1', data[:8] + version, sample_rate, payload_bytes, channels = struct.unpack('>IIII', data[8:24]) + frames = struct.unpack('>Q', data[24:32])[0] + assert version == 1 + assert sample_rate == 4000 + assert payload_bytes == 64 + assert channels == 8 + assert frames == 40 + assert len(data) == 32 + frames * payload_bytes + + first = data[32:96] + pairs = [struct.unpack('>iI', first[i:i+8]) for i in range(0, 64, 8)] + values = [value for value, quality in pairs] + qualities = [quality for value, quality in pairs] + assert values == [0, -1, 1, 0, 0, -71, 71, 0], values + assert qualities == [0] * 8, qualities + print({'status': 'REPLAY-BUNDLE/PASSED', 'frames': frames, 'values0': values}) + + - name: Upload Windows replay tools and evidence + if: runner.os == 'Windows' + uses: actions/upload-artifact@v4 + with: + name: windows-sv-replay-${{ github.sha }} + path: | + build-replay/Release/ariec61850_sv_replay_prepare.exe + build-replay/Release/ariec61850_sv_replay_live.exe + build-replay/simple_fault.arsvr + if-no-files-found: error diff --git a/.github/workflows/urcb-event-hard-profile-ci.yml b/.github/workflows/urcb-event-hard-profile-ci.yml new file mode 100644 index 000000000..ea12e7b03 --- /dev/null +++ b/.github/workflows/urcb-event-hard-profile-ci.yml @@ -0,0 +1,80 @@ +name: URCB Event Hard Profile CI + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + urcb-event-hard-profile: + name: URCB Event Hard / ${{ matrix.compiler }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + compiler: [gcc, clang] + + steps: + - uses: actions/checkout@v4 + + - name: Select compiler + 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 URCB event hard profile + run: cmake -S embedded/urcb_event_hard_profile -B build-urcb-event-hard + + - name: Build URCB event hard profile + run: cmake --build build-urcb-event-hard --parallel + + - name: Run URCB event smokes + run: >- + ctest --test-dir build-urcb-event-hard + --output-on-failure + --output-junit urcb-event-ctest.xml + + - name: Prove exception runtime is absent + shell: bash + run: | + set -euo pipefail + for binary in \ + build-urcb-event-hard/ariec61850_hard_mms_urcb_event_smoke \ + build-urcb-event-hard/ariec61850_hard_mms_urcb_event_connection_smoke; do + if nm -C "$binary" | grep -E '__cxa_throw|__cxa_begin_catch|__cxa_end_catch|__gxx_personality_v0'; then + echo "exception runtime symbol found in $binary" >&2 + exit 1 + fi + done + echo 'NO_EXCEPTION_RUNTIME=PASS' + + - name: Report footprint + shell: bash + run: | + set -euo pipefail + { + echo "compiler=${{ matrix.compiler }}" + size \ + build-urcb-event-hard/ariec61850_hard_mms_urcb_event_smoke \ + build-urcb-event-hard/ariec61850_hard_mms_urcb_event_connection_smoke + } | tee build-urcb-event-hard/urcb-event-footprint.txt + + - name: Upload URCB event evidence + uses: actions/upload-artifact@v4 + with: + name: urcb-event-hard-${{ matrix.compiler }}-${{ github.event.pull_request.head.sha || github.sha }} + if-no-files-found: error + retention-days: 14 + path: | + build-urcb-event-hard/urcb-event-ctest.xml + build-urcb-event-hard/urcb-event-footprint.txt diff --git a/CMakeLists.txt b/CMakeLists.txt index 02992fed8..39c5e1a8b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,7 +6,7 @@ project(ARIEC61850Cpp LANGUAGES CXX) option(ARIEC61850_BUILD_TESTS "Build ARIEC61850 C++ regression tests" ON) -option(ARIEC61850_BUILD_TOOLS "Build read-only evidence tools" ON) +option(ARIEC61850_BUILD_TOOLS "Build CLI and evidence tools" ON) option(ARIEC61850_BUILD_FUZZERS "Build LLVM libFuzzer targets" OFF) option(ARIEC61850_ENABLE_SANITIZERS "Enable AddressSanitizer and UndefinedBehaviorSanitizer" OFF) option(ARIEC61850_WARNINGS_AS_ERRORS "Treat compiler warnings as errors" ON) @@ -130,6 +130,12 @@ if(ARIEC61850_BUILD_TOOLS) target_compile_features(ariec61850_pagination_probe PRIVATE cxx_std_20) ariec61850_apply_warnings(ariec61850_pagination_probe) ariec61850_apply_sanitizers(ariec61850_pagination_probe) + + add_executable(ariec61850_sv_injector tools/sv_injector.cpp) + target_link_libraries(ariec61850_sv_injector PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_sv_injector PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_sv_injector) + ariec61850_apply_sanitizers(ariec61850_sv_injector) endif() if(ARIEC61850_BUILD_TESTS) @@ -274,6 +280,9 @@ if(ARIEC61850_BUILD_TESTS) add_test( NAME ariec61850_pagination_probe_help COMMAND ariec61850_pagination_probe --help) + add_test( + NAME ariec61850_sv_injector_loopback_smoke + COMMAND ariec61850_sv_injector --frames 160 --json) endif() endif() diff --git a/ESP32_WAVESHARE_ROADMAP.md b/ESP32_WAVESHARE_ROADMAP.md index 34ff38045..fd21f1e21 100644 --- a/ESP32_WAVESHARE_ROADMAP.md +++ b/ESP32_WAVESHARE_ROADMAP.md @@ -1,4 +1,4 @@ -# arstack61850 — Public Alpha + Waveshare ESP32-S3 Roadmap +# arstack61850 — Embedded / ESP32 Roadmap ## Product direction @@ -11,9 +11,26 @@ industrial implementation with two supported deployment classes: 2. **Embedded / runtime** — bounded protocol engine suitable for ESP32-class MCUs first and STM32/NXP-class targets later. -The first reference MCU is the **Waveshare ESP32-S3-POE-ETH-8DI-8DO**. +The **first process-bus transmit reference is now ESP32-P4** using the official +ESP-IDF Ethernet abstraction and a board-specific EMAC/PHY configuration owned +by the application. The previously selected **Waveshare ESP32-S3-POE-ETH-8DI-8DO** +remains a useful secondary I/O reference with W5500 Ethernet, but protocol code +must not depend on either board. -## Reference hardware facts +## Reference hardware roles + +### ESP32-P4 — first SV timing / raw-Ethernet proof + +Use ESP32-P4 for the first active Sampled Values trial because it provides a +stronger MCU target for deterministic process-bus experimentation while still +using the same `esp_eth` adapter boundary. + +The protocol stack must receive an already-started `esp_eth_handle_t`; board PHY +selection, RMII pins, clocking and link initialization remain application code. +The first proof is deliberately **untagged SV**. VLAN/PCP acceptance is a later +hardware/driver gate rather than a hidden protocol-core assumption. + +### Waveshare ESP32-S3-POE-ETH-8DI-8DO — secondary I/O IED reference Board: Waveshare ESP32-S3-POE-ETH-8DI-8DO, SKU 32108. @@ -25,10 +42,11 @@ Board: Waveshare ESP32-S3-POE-ETH-8DI-8DO, SKU 32108. - DI1..DI8: GPIO4..GPIO11. - Digital outputs are exposed through TCA9554PWR, I2C address 0x20. - Isolated RS485 and CAN are available for future gateway use. -- W5500 supports MACRAW at chip level. The preferred ESP-IDF W5500 driver - operates the device in MAC RAW mode and feeds the ESP software TCP/IP stack. - Raw SV/GOOSE and lwIP TCP/MMS therefore share one Ethernet interface instead - of arstack independently allocating W5500 hardware sockets. + +When this board is used, the official ESP-IDF W5500 driver should own the chip +MACRAW path. arstack should not independently program W5500 hardware sockets. +Raw SV/GOOSE and lwIP TCP/MMS must share one Ethernet interface through platform +adapters. Raw Layer-2 SV/GOOSE is an Ethernet feature. Wi-Fi is not a substitute for the reference process-bus path. @@ -37,28 +55,30 @@ reference process-bus path. ### Embedded core -`ARIEC61850::embedded_core` may contain wire codecs and bounded runtime state, -but must not depend on: +`ARIEC61850::embedded_core` and platform slices may contain wire codecs and +bounded runtime state, but must not make the wire protocol depend on: - WinSock or BSD/POSIX sockets; - filesystem, iostream or CLI code; - PCAP/evidence tooling; - COMTRADE; - full SCL parsing; -- live-discovery host workflows. +- live-discovery host workflows; +- ESP-IDF, FreeRTOS, W5500, RMII or a specific PHY type. Platform ownership is injected through small callback HAL contracts. ESP-IDF, -lwIP, FreeRTOS and W5500 details stay outside the protocol engine. +lwIP, FreeRTOS and board details stay outside the protocol engine. ### Memory discipline -Steady-state publisher/subscriber/runtime paths should move toward: +Steady-state publisher/subscriber/runtime paths move toward: - caller-owned buffers; - bounded capacities; - no per-frame allocation; - no RTTI; -- eventually no C++ exceptions in the MCU core. +- no exception control flow in real-time paths; +- eventually a fully `-fno-exceptions` MCU build. Existing `std::vector`/`std::string` data models may remain as configuration-time containers during migration. They must not force allocation on the 4 kHz SV @@ -66,7 +86,7 @@ steady-state encode/transmit path. ### Compatibility -Existing host APIs remain source-compatible where practical. Allocation-free +Existing host APIs remain source-compatible where practical. Allocation-bounded APIs are added underneath host convenience wrappers, so Windows/Linux users do not lose ergonomics while the same codec becomes MCU-capable. @@ -76,19 +96,22 @@ not lose ergonomics while the same codec becomes MCU-capable. ## Track A — Public Alpha -### A0. Live read-only MMS foundation — CURRENT +### A0. Live read-only MMS foundation — ACCEPTED ON CURRENT PRIMARY IED -Acceptance: +Acceptance achieved on the current OCR7SR12 laboratory target includes: -- TPKT/COTP/Session/Presentation/ACSE association works against live vendor IED. -- GetNameList domains, variables and DataSets works. -- GetVariableAccessAttributes works. -- Read works. -- DataSet and RCB inventories are preserved. -- Dynamic/unbound RCB state is represented without false failure. -- RCB planner/availability evidence is read-only. -- GCC, Clang and MSVC CI pass. -- ASan/UBSan and fuzz corpus pass. +- TPKT/COTP/Session/Presentation/ACSE association; +- GetNameList domains, variables and DataSets; +- GetVariableAccessAttributes; +- Read; +- DataSet and RCB inventories; +- dynamic/unbound RCB state without false failure; +- read-only RCB planner/availability evidence; +- bounded multi-page `GetNameList` continuation, including a physical 48-page + / 4,758-name sequence; +- GCC, Clang and MSVC regression coverage. + +Multi-vendor evidence remains separate from this one-target acceptance. ### A1. Public discovery/model alpha @@ -106,17 +129,8 @@ Deliverables: - README quick-start for Windows/Linux; - explicit feature matrix: implemented / partial / host-only / embedded-ready. -Acceptance: - -- clean configure/build/test from a fresh clone; -- at least three repeat live cycles on the current test IED/simulator; -- no diagnostic regression; -- reproducible JSON parity report checked into evidence. - ### A2. Public alpha release -Goal: tag a release that is genuinely useful even before full ARIEC61850 parity. - Required public surface: - MMS client association + confirmed read services; @@ -139,9 +153,9 @@ Not release-blocking: ## Track E — ESP32 reference product -### E0. Embedded architecture baseline — IN PROGRESS +### E0. Embedded architecture baseline — IMPLEMENTED -Deliverables: +Delivered: - standalone `embedded/CMakeLists.txt`; - `ARIEC61850::embedded_core`; @@ -149,19 +163,16 @@ Deliverables: - source-boundary audit; - callback HAL for raw Ethernet, connected TCP and clocks; - configurable ESP32 small-capacity profile; -- Waveshare board facts/profile. - -Exit gate: +- ESP-IDF raw-Ethernet/clock adapter boundary. -- GCC and Clang embedded host-simulation builds link every embedded source; -- host-only dependency audit is clean; -- legacy exception debt is measured and cannot silently expand. +Remaining hardening: -### E1. Allocation-bounded SV encoder — IN PROGRESS +- remove the shared codec's compile-time C++ exception dependency from the + MCU-specific build rather than merely avoiding exceptions in the hot path. -Goal: the 4 kHz publisher loop does not allocate a new frame buffer per sample. +### E1. Allocation-bounded SV encoder — IMPLEMENTED -API direction: +The first steady-state API direction is now implemented: ```cpp std::optional SampledValuesFrameCodec::encoded_size(...) noexcept; @@ -170,78 +181,92 @@ wire::EncodeResult SampledValuesFrameCodec::encode_into( std::span destination) noexcept; ``` -Host `encode()` remains as a convenience wrapper. - -Acceptance: +The caller can reuse the same Ethernet buffer continuously. Host `encode()` +remains a convenience wrapper. -- bounded encoder is byte-identical to existing C#/C++ golden vectors; -- buffer-too-small returns required size and does not partially report success; -- same caller-owned Ethernet buffer can be reused continuously; -- timestamp serialization has a no-allocation path; -- sanitizer/fuzzer/host regression remains green. +### E2. Portable SV publisher runtime — IMPLEMENTED / PHYSICAL EVIDENCE PENDING -### E2. ESP-IDF W5500 Ethernet adapter +`SampledValuesPublisher` now owns only protocol runtime state. It does not create +threads or sleep. The application provides the monotonic timestamp and decides +whether a FreeRTOS task or high-resolution timer drives the stream. -Goal: map `embedded::RawEthernetPort` to the official ESP-IDF W5500 Ethernet -MACRAW path without leaking ESP-IDF/W5500 APIs into protocol code. +Implemented behavior: -Preferred architecture: +- 4,000 samples/s first profile; +- caller-owned Ethernet frame buffer; +- sample counter advances after successful full-frame transmit only; +- configured sample-counter wrap; +- at most one transmit attempt per `poll()`; +- late scheduler wake-up does not generate catch-up bursts; +- TX/encode/lateness statistics; +- allocation-free payload writer for common INT32 + quality pairs. -```text -arstack raw SV/GOOSE ----> esp_eth raw Ethernet transmit ----+ - | -MMS TCP <---- lwIP / BSD socket API <---- esp_netif --------+--> ESP-IDF W5500 MACRAW -``` +Host smoke acceptance covers due/not-due behavior, 3999→0 wrap, late wake-up, +TX timeout and encode buffer failure. -The ESP-IDF W5500 driver owns the chip-level MACRAW operation. arstack should -not independently program W5500 hardware sockets when using that driver. +### E3. ESP32-P4 SV Publisher hardware proof — CURRENT -Responsibilities: - -- initialize the official ESP-IDF W5500 SPI Ethernet driver with board pins; -- attach the driver to `esp_netif`/lwIP for TCP/MMS; -- expose raw-frame transmit callback for GOOSE/SV; -- preserve destination/source MAC, VLAN tag and EtherType exactly; -- expose PHY/link state; -- instrument TX errors, would-block and frame counts. - -Acceptance on host/mock first: - -- adapter-facing policy can be unit-tested with fake transmit backend; -- protocol core does not include ESP-IDF/W5500 headers. +Reference first profile: -Acceptance on board: +- 50 Hz system; +- 80 samples/cycle = 4,000 sample instants/s; +- one ASDU/frame initially; +- EtherType `0x88BA`; +- APPID `0x4001`; +- multicast destination `01:0C:CD:04:00:01`; +- source MAC from the physical interface; +- `confRev=1` for the run; +- `smpSynch=0` until synchronized time is independently proven; +- no `refrTm` for the first throughput proof; +- untagged Ethernet first. + +Cross-compile gate: + +- real ESP32-P4 target; +- ESP-IDF v6.0.2; +- firmware component contains the first SV TX dependency slice only; +- current transition enables `CONFIG_COMPILER_CXX_EXCEPTIONS=y` because shared + desktop validation/convenience APIs still contain `throw/catch`; publisher hot + path must not use exception control flow. + +Physical acceptance: + +- raw SV frame visible with `eth.type == 0x88ba`; +- exact destination/source MAC and APPID; +- Wireshark decodes one ASDU correctly; +- sustained 4,000 frames/s target or an explicitly documented alternative; +- no publisher-generated sample-counter discontinuity; +- no catch-up burst after scheduler lateness; +- no steady-state frame-buffer allocation; +- short capture, 10-minute soak, then 1-hour soak; +- CPU usage, heap delta/minimum free heap, TX errors and lateness statistics + recorded. -- Ethernet obtains link and normal IP traffic works through lwIP; -- raw custom EtherType frame visible in Wireshark; -- VLAN-tagged frame visible without mutation; -- SV EtherType 0x88BA frame visible while normal TCP/IP remains usable. +See `docs/ESP32_P4_SV_FIRST_TRIAL.md` for the execution checklist. -### E3. ESP32-S3 SV Publisher hardware proof +### E4. Exception-free MCU SV TX slice — NEXT HARDENING GATE -Start with synthetic waveform; do not block on ADC hardware. +Goal: build the first SV publisher firmware with C++ exceptions disabled again, +without changing desktop convenience APIs. -Reference first profile: +Preferred direction: -- 50 Hz system; -- 80 samples/cycle = 4,000 sample instants/s; -- one ASDU initially; -- fixed APPID and multicast destination; -- sample counter wraps according to configured rate; -- configuration revision fixed during a run; -- timestamp optional for the first throughput proof, then enabled. +- separate status-returning/no-throw transmit codec implementation units from + throw-based desktop wrappers where necessary; +- add non-throwing MAC/config construction paths suitable for static firmware + configuration; +- keep decode/diagnostic convenience APIs host-side when they are not required + by the first publisher image; +- CI with ESP32-P4 and `CONFIG_COMPILER_CXX_EXCEPTIONS=n`. Acceptance: -- sustained 4,000 frames/s target or explicitly documented multi-ASDU packing - alternative; -- no steady-state frame-buffer allocation; -- no sample-counter discontinuity generated by publisher logic; -- packet capture decodes as IEC 61850 SV; -- 10-minute soak first, then 1-hour soak; -- CPU usage, heap delta, minimum free heap and TX errors recorded. +- ESP-IDF P4 cross-compile green with exceptions disabled; +- no `throw`/`catch` reachable or required by the MCU SV TX component; +- publisher golden bytes remain unchanged; +- host GCC/Clang/MSVC API/regression suite remains green. -### E4. Board I/O HAL +### E5. Board I/O HAL — Waveshare ESP32-S3 secondary reference Goal: make the Waveshare hardware useful as a real substation I/O prototype. @@ -258,21 +283,13 @@ Outputs: - safe startup state: outputs off unless application explicitly restores state; - command watchdog/failsafe hooks. -Acceptance: - -- deterministic read of all eight DIs; -- deterministic set/reset of all eight DOs; -- power-cycle safe-state test; -- no IEC 61850 code knows GPIO/I2C details. - -### E5. Minimal MMS server foundation +### E6. Minimal MMS server foundation -This is required for the board to become an actual IEC 61850 I/O IED. +This is required for an embedded board to become an actual IEC 61850 I/O IED. +Implement a static, bounded server model first. Do not port desktop dynamic model +discovery into the MCU server. -Implement a static, bounded server model first. Do not port desktop dynamic -model discovery into the MCU server. - -Minimum server services: +Minimum services: 1. association acceptor; 2. MMS Initiate negotiation; @@ -283,7 +300,7 @@ Minimum server services: Initial static model proposal: ```text -ESP32S3IO +ARSTACKIO └── LD0 ├── LLN0 ├── LPHD1 @@ -293,57 +310,34 @@ ESP32S3IO Acceptance: -- IEDScout can associate; -- IEDScout can browse the static model; -- eight DI values can be read; +- an independent IEC 61850 engineering client can associate; +- the client can browse the static model; +- configured I/O values can be read; - server remains bounded under malformed/fuzzed MMS requests. -### E6. Simple IEC 61850 I/O IED - -Add controlled output operation only after read-only server browse/read is -stable. +### E7. Simple IEC 61850 I/O IED -Sequence: +Add controlled output operation only after read-only server browse/read is stable. +Then add a static DataSet, URCB first, data-change reports and BRCB later. -- simple Write where standards/model permit; -- IEC 61850 control model required for the exposed DOs; -- interlock/check hooks; -- operation timeout; -- command audit/result status. +### E8. GOOSE I/O extension -Reporting sequence: - -- static DataSet; -- URCB first; -- data-change report for DI state; -- BRCB later, not a first-I/O-IED requirement. - -Acceptance: - -- IEDScout browse/read/control proof; -- input change generates expected report; -- output command changes physical DO and reflected model state; -- negative tests for invalid control/FC/reference. - -### E7. GOOSE I/O extension - -After raw MAC transport is proven by SV, GOOSE is a natural extension. - -Targets: +After raw MAC transport is proven by SV: - DI state as GOOSE publisher; - GOOSE subscriber mapped to selected DO only through application safety policy; - retransmission schedule and TAL supervision; -- VLAN priority validation. +- VLAN priority validation on a hardware/driver path that supports it correctly. -### E8. Industrialization / STM32 migration +### E9. Industrialization / STM32-NXP migration -ESP32 is the reference accessibility target, not the final certification claim. -The same embedded core should later move to STM32H7/NXP-class hardware. +ESP32 is the accessibility/reference target, not a certification claim. The same +embedded core should later move to STM32H7/NXP-class hardware by replacing +platform adapters and capacities, not protocol semantics. Industrialization work: -- `-fno-exceptions` embedded core; +- fully exception-free MCU core; - replace remaining hot-path dynamic containers with bounded storage/views; - explicit memory budget report; - deterministic scheduler/timing hooks; @@ -356,30 +350,30 @@ Industrialization work: # Priority rule -Until Public Alpha is tagged: - -- approximately **70% effort** goes to public-useful host protocol parity, - documentation and real-IED evidence; -- approximately **30% effort** goes to embedded architecture and the SV hardware - path. +Until Public Alpha is tagged, host parity and embedded architecture progress in +parallel. Embedded changes that prevent architectural debt — allocation-bounded +wire encoding, HAL boundaries, real cross-compile gates and real-time pacing +rules — are performed before adding new protocol surface. -Exception: embedded changes that prevent architectural debt (allocation-free -wire encoding, HAL boundaries, compile gates) are performed immediately before -new protocol features are ported. +The immediate embedded order is: -After Public Alpha: - -- finish E2/E3 hardware SV proof; -- then shift primary development toward E5/E6 MMS server + I/O IED. +1. ESP32-P4 SV cross-compile; +2. physical untagged SV capture and timing evidence; +3. exception-free P4 SV TX hardening; +4. VLAN/priority path validation; +5. GOOSE raw Ethernet; +6. bounded MMS server / I/O IED. # Definition of success The project succeeds when the same protocol implementation can demonstrate all -of these without forks of the wire core: - -1. Windows/Linux engineering client can browse a real IED. -2. ESP32-S3 can publish standards-shaped SV frames over the ESP-IDF W5500 - MACRAW Ethernet interface. -3. ESP32-S3 can expose eight isolated inputs/outputs as a small IEC 61850 IED. -4. A later STM32/NXP port changes platform adapters and capacities, not protocol +of these without forks of IEC 61850 wire semantics: + +1. Windows/Linux engineering client can browse real IEDs. +2. ESP32-P4 can publish standards-shaped SV frames through the ESP-IDF Ethernet + abstraction with measured timing/heap evidence. +3. The MCU SV TX path can build with exceptions disabled. +4. A small ESP32 I/O reference can expose isolated inputs/outputs through a + bounded IEC 61850 server model. +5. A later STM32/NXP port changes platform adapters and capacities, not protocol semantics. diff --git a/MIGRATION_CHECKLIST.md b/MIGRATION_CHECKLIST.md index d2bc4d573..872e7dd4d 100644 --- a/MIGRATION_CHECKLIST.md +++ b/MIGRATION_CHECKLIST.md @@ -17,8 +17,14 @@ behavioral oracle until laboratory interoperability is complete. - [x] GOOSE wire codec and offline runtime supervision. - [x] Sampled Values PDU/frame codec, quality, payload, counter, and stream diagnostics. - [x] Synthetic PCAP equivalence, sanitizer, mutation, and libFuzzer hardening. +- [x] Allocation-bounded SV publisher runtime with caller-owned Ethernet buffer, successful-TX counter progression, and no-catch-up pacing. +- [x] Platform-neutral raw-Ethernet/clock HAL plus ESP-IDF `esp_eth_transmit()` adapter. +- [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. +- [~] Real-time SV publisher timing-health validation: deterministic host/CI pacing implemented; physical ESP32-P4 evidence pending. +- [ ] 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 @@ -144,7 +150,7 @@ behavioral oracle until laboratory interoperability is complete. - [x] C# and C++ same-IED OCR7SR12 structural/type/runtime comparison accepted with zero blocking findings. - [x] Primary-vendor OCR7SR12 ten-cycle acceptance: 10/10 stable discovery cycles, full 286/286 RCB and control-block gates, 3/3 StableProceed contention cycles, and 13/13 fresh associations. - [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. -- [ ] Pagination continuation evidence accepted on a target that requires more than one page. +- [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`. ### Later Phase 4 work diff --git a/docs/EMBEDDED_QA_GATES.md b/docs/EMBEDDED_QA_GATES.md new file mode 100644 index 000000000..08c443148 --- /dev/null +++ b/docs/EMBEDDED_QA_GATES.md @@ -0,0 +1,101 @@ +# Embedded QA gates + +arstack61850 deliberately separates software evidence from physical-hardware evidence. A green CI run must never be described as proof of protection-grade timing or successful Ethernet transmission on silicon. + +## Gate A — portable embedded host simulation + +Runs on Linux with both GCC and Clang, using the MCU-oriented source boundary and host-service exclusions. + +Required evidence: + +- embedded source-boundary audit passes; +- strict warnings-as-errors build passes; +- full embedded link smoke passes; +- SV publisher edge-case smoke passes; +- deterministic 8,000-frame simulation passes at a virtual 4,000 samples/s; +- C++ publisher/encoder emits a two-second classic-PCAP capture; +- an independent pure-Python reference implementation rebuilds the expected Ethernet + process-bus + BER SV stream and requires the entire PCAP to match byte-for-byte; +- the independent parser also validates destination/source MAC, EtherType `0x88BA`, APPID `0x4001`, declared length, reserved fields, `noASDU`, `svID`, DataSet reference, `confRev`, `smpCnt`, `smpSynch`, `smpRate`, `smpMod`, 64-byte synthetic payload, and exact 250 us capture cadence; +- zero simulated lateness is reported when the supplied monotonic clock is exact; +- JUnit, PCAP, and machine-readable JSON QA evidence are retained as GitHub Actions artifacts for both GCC and Clang. + +The external verifier intentionally does not import or call the arstack61850 decoder. This prevents a matching encoder/decoder defect from satisfying Gate A merely because both implementations accept the same incorrect wire image. + +This gate proves deterministic software behavior and exact first-trial wire construction under a virtual clock. It does not measure wall-clock performance. + +## Gate B — real ESP32-P4 cross-compile + +Uses the official ESP-IDF v6.0.2 toolchain with target `esp32p4`. + +Required evidence: + +- the narrow arstack61850 ESP-IDF component compiles and links; +- the compile-only integration smoke app builds; +- the flashable first-trial SV firmware builds and links; +- the resulting ESP-IDF flasher manifest identifies chip `esp32p4`; +- no host-only TCP/filesystem/capture services leak into the embedded component boundary. + +This gate proves that the code is accepted by the real ESP32-P4 RISC-V compiler/linker and ESP-IDF component system. + +## Gate C — flashable artifact completeness + +The ESP-IDF workflow must fail if any required deployment output is missing, empty, malformed, targets another chip, or disagrees with the expected first-trial flash layout. + +The authoritative deployment source for this gate is ESP-IDF's generated `flasher_args.json`. Offset strings are normalized to numeric addresses before comparison so equivalent formatting cannot create a false failure. The gate requires: + +- `extra_esptool_args.chip == esp32p4`; +- bootloader at `0x2000`; +- partition table at `0x8000`; +- application at `0x10000`; +- every file referenced by that three-image manifest exists and is non-empty; +- the generated `flash_args` companion exists and is non-empty; +- the repository-controlled `sdkconfig.defaults` baseline exists and is non-empty. + +`flash_args` is retained for flashing convenience but is not separately reparsed as a second authority; duplicating ESP-IDF's own manifest interpretation would make the CI brittle without adding independent deployment evidence. + +The retained `esp32p4-sv-trial-firmware-*` artifact contains: + +- application `.bin`; +- application `.elf`; +- application `.map`; +- bootloader image; +- partition-table image; +- `flash_args`; +- validated `flasher_args.json`; +- `sdkconfig.defaults` used as the repository-controlled configuration baseline; +- generated `ARSTACK_BUILD_PROFILE.json` recording `FLASHABLE/READY`, target chip, image sizes, and flash layout; +- generated `ARSTACK_BUILD_FILE_LIST.txt` inventory for reproducibility/debugging; +- `SHA256SUMS` covering the deployment-critical images, manifests, inventory, and configuration baseline; +- firmware-specific flashing/behavior README. + +If Gate C fails, CI uploads a short-lived `esp32p4-gate-c-diagnostics-*` artifact containing the actual generated manifest, flash arguments, inventory, and core images. This keeps failures inspectable without weakening the success criteria. + +Passing Gate C means a developer can download a CI-produced firmware set instead of rebuilding it locally, verify the retained files, and use the ESP-IDF-generated flash layout for the target board. + +## Gate D — physical ESP32-P4 Ethernet acceptance + +This gate requires the actual board and an isolated laboratory Ethernet segment. + +Required evidence for the first Sampled Values proof: + +1. ESP32-P4 boots the Gate-C firmware and the PHY reports link up. +2. Wireshark observes EtherType `0x88BA` frames from the board. +3. Destination MAC and APPID are exactly the configured first-trial values. +4. Observed packet rate is near 4,000 frames/s under the 50 Hz / 80 samples-per-cycle profile. +5. `smpCnt` behavior matches the accepted publisher semantics and wraps at 4,000. +6. No unexpected catch-up burst appears after scheduler delay. +7. TX/encode failures, timer coalescing, lateness, and heap-watermark telemetry are recorded. +8. Short run, 10-minute soak, and one-hour soak are progressively accepted. + +Only Gate D can validate real PHY wiring, EMAC/DMA behavior, scheduler jitter on silicon, cable transmission, switch behavior, and packet continuity observed by another device. + +## Status vocabulary + +Use these terms in issues, PRs, release notes, and test evidence: + +- `SIMULATED/PASSED` — Gate A passed, including the independent exact-byte PCAP oracle. +- `ESP32P4/CROSS-COMPILED` — Gate B passed. +- `FLASHABLE/READY` — Gate C passed. +- `HARDWARE/PASSED` — Gate D passed on a recorded physical run. + +Never promote `FLASHABLE/READY` to `HARDWARE/PASSED` without physical evidence. diff --git a/docs/ESP32_P4_SV_FIRST_TRIAL.md b/docs/ESP32_P4_SV_FIRST_TRIAL.md new file mode 100644 index 000000000..54a6e0298 --- /dev/null +++ b/docs/ESP32_P4_SV_FIRST_TRIAL.md @@ -0,0 +1,246 @@ +# ESP32-P4 Sampled Values first trial + +This is the first active process-bus transmit proof for arstack61850. It is a +controlled laboratory step, not a protection or metering accuracy claim. + +## Target + +Start with an ESP32-P4 board that exposes the internal Ethernet EMAC through an +RMII PHY and for which the application already owns a started `esp_eth_handle_t`. +The arstack protocol code does not configure board-specific PHY pins. That stays +in the ESP-IDF application so the same stack can be reused with different P4 +boards and later with other MCU families. + +The first trial intentionally uses an **untagged** Ethernet frame. ESP-IDF v6.0.2 +still documents VLAN-tagged Ethernet frames as unsupported by the basic Ethernet +path. VLAN transmission is therefore a separate hardware/driver acceptance gate, +not something the protocol core should hide or emulate. + +Useful Espressif references: + +- ESP32-P4 Ethernet: https://docs.espressif.com/projects/esp-idf/en/stable/esp32p4/api-reference/network/esp_eth.html +- ESP32-P4 getting started: https://docs.espressif.com/projects/esp-idf/en/stable/esp32p4/get-started/index.html +- ESP-IDF C++ support: https://docs.espressif.com/projects/esp-idf/en/stable/esp32p4/api-guides/cplusplus.html + +## What this branch provides + +- `SampledValuesPduCodec::encode_into()` and + `SampledValuesFrameCodec::encode_into()` reuse caller-owned buffers. +- `SampledValuesPublisher` provides deterministic sample-count progression and a + no-catch-up pacing policy. +- `SampledValuesPayloadWriter` updates common INT32 + quality pairs in a + pre-sized `seqOfData` buffer without allocating. +- `embedded::RawEthernetPort` is the platform-neutral raw Ethernet contract. +- `ports/esp_idf` maps that contract to `esp_eth_transmit()` and exposes + `esp_timer_get_time()` as a monotonic microsecond clock. +- the ESP-IDF component intentionally cross-compiles only the first SV transmit + slice rather than pulling the complete desktop MMS/discovery stack into the + first firmware image. + +The steady-state publisher path is `noexcept` and does not resize the frame, +ASDU, or payload containers. Allocate and size configuration objects before the +publisher loop starts. + +## Transitional C++ exception requirement + +ESP-IDF disables C++ exceptions by default. The shared desktop BER, Ethernet, +and SV codec source files still contain exception-based convenience/validation +APIs even though the first publisher hot path uses status-returning +`encode_into()` and raw-Ethernet operations. + +For this first cross-compile/hardware proof, enable: + +```text +CONFIG_COMPILER_CXX_EXCEPTIONS=y +``` + +The repository's `ports/esp_idf/smoke_app/sdkconfig.defaults` already does this +for CI. This is a **transitional portability dependency**, not a claim that +exceptions belong in the 4 kHz real-time path. Removing the remaining +compile-time dependency on C++ exceptions is tracked as a separate embedded +hardening gate. Do not throw exceptions from the publisher task. + +## Reference first profile + +Use this conservative initial profile: + +| Item | First-trial value | +| --- | --- | +| System frequency | 50 Hz | +| Samples/cycle | 80 | +| Sample instants | 4,000/s | +| ASDUs/frame | 1 | +| Ethernet | untagged first | +| EtherType | `0x88BA` | +| Destination MAC | `01:0C:CD:04:00:01` | +| APPID | `0x4001` | +| `confRev` | `1` for the whole run | +| `smpSynch` | `0` until a real synchronized time source is proven | +| `refrTm` | omitted for throughput proof | +| `smpCnt` wrap | `4000` for this first profile | + +The source MAC must be the actual Ethernet MAC assigned to the ESP32-P4 +interface. Do not copy the synthetic host-test source MAC into a physical unit. + +## ESP-IDF component integration + +Add the repository's `ports/esp_idf` directory as an extra component in the +application-level `CMakeLists.txt`: + +```cmake +set(EXTRA_COMPONENT_DIRS + "${CMAKE_CURRENT_LIST_DIR}/../../arstack61850/ports/esp_idf") +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(arstack_sv_trial) +``` + +The component compiles only the dependencies needed for the first SV transmit +slice plus the ESP-IDF adapter. Host TCP discovery, reporting, GOOSE, PCAP, +COMTRADE, SCL parsing, filesystem and CLI sources are intentionally excluded +from this first firmware target. + +## Minimal publisher setup + +The board application is responsible for installing and starting Ethernet. Once +it has a valid `esp_eth_handle_t eth_handle`, build the publisher once and reuse +it: + +```cpp +#include "ariec61850/embedded/profile.hpp" +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/ports/esp_idf/ethernet_port.hpp" +#include "ariec61850/sampled_values/frame.hpp" +#include "ariec61850/sampled_values/payload_writer.hpp" +#include "ariec61850/sampled_values/publisher.hpp" + +#include +#include + +using namespace ar::iec61850; + +ports::esp_idf::RawEthernetContext ethernet_context{eth_handle}; +auto raw_port = ports::esp_idf::make_raw_ethernet_port(ethernet_context); +auto clock = ports::esp_idf::make_monotonic_clock(); + +sampled_values::SampledValueAsdu asdu; +asdu.sv_id = "ESP32P4/LLN0$MSVCB01"; +asdu.data_set_reference = "ESP32P4/LLN0$PhsMeas1"; +asdu.configuration_revision = 1; +asdu.sample_synchronization = 0; +asdu.sample_rate = std::uint16_t{4000}; +asdu.sample_mode = std::uint16_t{1}; +asdu.sample_payload.resize(64); // eight INT32 + quality pairs; allocate once + +std::array dst{0x01, 0x0C, 0xCD, 0x04, 0x00, 0x01}; +std::array src{}; // fill from the actual Ethernet MAC + +sampled_values::SampledValuesFrame frame{ + ethernet::MacAddress{dst}, + ethernet::MacAddress{src}, + std::nullopt, + 0x4001, + 0, + 0, + sampled_values::SampledValuesPdu{{asdu}}}; + +std::array + ethernet_buffer{}; + +sampled_values::SampledValuesPublisher publisher{ + frame, + ethernet_buffer, + raw_port, + sampled_values::SampledValuesPublisherConfig{ + 4000, + std::uint16_t{4000}, + 0, + true}}; +``` + +For every sample instant, update the already-sized payload in place. The exact +channel order, scaling and quality semantics must match the DataSet/profile that +will eventually describe this stream; the following is only a synthetic wire +proof: + +```cpp +auto payload = std::span{frame.pdu.asdus.front().sample_payload}; + +sampled_values::SampledValuesPayloadWriter::write_int32_quality_pair( + payload, 0, synthetic_ia, 0); +sampled_values::SampledValuesPayloadWriter::write_int32_quality_pair( + payload, 1, synthetic_ib, 0); +// ...pre-sized remaining pairs... + +const auto result = publisher.poll(clock.monotonic_us()); +``` + +`poll()` never sends more than one frame per call. If the FreeRTOS task wakes up +late, the publisher records lateness and anchors the next deadline after the +actual attempt instead of emitting a burst of back-to-back catch-up frames. +This mirrors the pacing lesson learned from the C# transport implementation. + +If a high-resolution timer already owns the 4 kHz cadence, call +`publish_now(clock.monotonic_us())` once per tick instead. Do not call both +pacing modes for the same stream session. + +## Recommended FreeRTOS ownership + +For the first hardware proof: + +1. Use one dedicated high-priority publisher task pinned according to the board + application's CPU/task policy. +2. Prepare strings, vectors, frame configuration and the Ethernet buffer before + entering the steady-state loop. +3. Update only payload bytes and runtime counter state in the hot path. +4. Do not log every frame. Aggregate counters and print them at a slow interval. +5. Avoid dynamic allocation, filesystem work, JSON, Wi-Fi work or UI activity in + the publisher task. +6. If `esp_eth_transmit()` returns timeout/error, retain the same `smpCnt`; the + next successful transmission will not silently skip a publisher-generated + counter value. +7. Do not use C++ exception control flow in the publisher task even while the + transitional project-level exception setting is enabled. + +ESP-IDF documents that short/frequent Ethernet traffic can require DMA descriptor +and buffer tuning. If TX cannot sustain the target, inspect Ethernet DMA buffer +size/count configuration before changing IEC 61850 wire semantics. + +## Wireshark acceptance + +Capture on an isolated lab switch/TAP and filter: + +```text +eth.type == 0x88ba +``` + +First acceptance requires: + +- destination multicast MAC is exact; +- source MAC matches the ESP32-P4 interface; +- EtherType is `0x88BA`; +- APPID is `0x4001`; +- one ASDU decodes; +- `svID`, DataSet reference and `confRev` remain stable; +- `smpCnt` is continuous and wraps at the configured point; +- no scheduler-late catch-up bursts are generated by the publisher logic; +- no steady-state growth in application heap attributable to the arstack + publisher path; +- TX errors/timeouts are counted and visible. + +Run a short functional capture first, then a 10-minute soak. If stable, extend to +one hour. Record at minimum: + +- `frames_sent`; +- `transmit_failures`; +- `late_polls`; +- `maximum_lateness_us`; +- current free heap and minimum-ever free heap; +- observed packets/s in Wireshark; +- any counter gap/duplicate/out-of-order finding from an independent subscriber. + +## What this proof does not claim + +A successful first trial proves the C++ wire core, MCU portability boundary, +raw-Ethernet adapter and basic pacing path can cooperate on ESP32-P4. It does not +yet prove IEC 61869-9 measurement scaling, PTP synchronization, VLAN priority, +protection-grade timing, multi-vendor interoperability, or certification. diff --git a/docs/PHYSICAL_PAGINATION_EVIDENCE_OCR7SR12.md b/docs/PHYSICAL_PAGINATION_EVIDENCE_OCR7SR12.md new file mode 100644 index 000000000..75e0d1096 --- /dev/null +++ b/docs/PHYSICAL_PAGINATION_EVIDENCE_OCR7SR12.md @@ -0,0 +1,87 @@ +# Physical MMS pagination evidence — OCR7SR12 + +This document records the physical `GetNameList` continuation evidence observed +on the OCR7SR12 laboratory target after the Phase 4C read-only interoperability +work. It closes the previously pending requirement to demonstrate pagination on +a response that actually requires multiple pages. + +## Environment + +- target: `OCR7SR12` +- endpoint used in the lab: `192.168.1.10:102` +- association profile selected by arstack61850: `BalancedApTitle` +- operation class: read-only MMS discovery +- executable: `ariec61850_pagination_probe` +- command limits: `--timeout-ms 30000 --max-pages 256 --max-names 65536 --max-domains 4096 --require-pagination` + +The Release build completed before the physical probe and the complete local CTest +suite reported 21/21 tests passing. + +## Accepted probe summary + +The physical run reported: + +```text +Read-only GetNameList pagination probe: +endpoint=192.168.1.10:102, +associationProfile=BalancedApTitle, +associationAttempts=1, +queries=9, +paginatedQueries=4, +continuationRequests=88, +accepted=true. +``` + +Observed domain enumeration: + +| Scope | Pages | Names | Continuations | Final state | +| --- | ---: | ---: | ---: | --- | +| VMD Domain | 1 | 4 | 0 | `moreFollows=false` | +| `OCR7SR12CTRL` NamedVariable | 27 | 2686 | 26 | final page 86 names | +| `OCR7SR12DR` NamedVariable | 3 | 276 | 2 | final page 76 names | +| `OCR7SR12MEAS` NamedVariable | 14 | 1347 | 13 | final page 47 names | +| `OCR7SR12PROT` NamedVariable | 48 | 4758 | 47 | final page 58 names | + +`NamedVariableList` was also queried in each domain. The PROT domain exposed one +list named `LLN0$DataSet`; CTRL, DR and MEAS returned zero lists in this bounded +probe. + +## Continuation behavior + +For every multi-page variable query, the continuation request used the final +name returned by the previous page as `continueAfter`. The relay then returned +the next bounded page and retained `moreFollows=true` until the final page. + +The largest observed sequence was `OCR7SR12PROT`: + +- 48 pages; +- 4,758 names; +- 47 continuation requests; +- normally 100 names/page; +- final page 58 names; +- final response `moreFollows=false`. + +This is materially stronger than a synthetic continuation test because the +client completed a long real-device sequence without repeating a page, losing +the continuation marker, exceeding the configured bounds, or terminating before +the relay cleared `moreFollows`. + +## Acceptance conclusion + +The Phase 4C.1 physical pagination continuation gate is **accepted for this +OCR7SR12 observation window**. + +The evidence proves that arstack61850 can execute bounded real-device MMS +`GetNameList` continuation across multiple logical-device domains, including a +48-page sequence, on one fresh association. It does not by itself prove +multi-vendor pagination interoperability or every server-specific continuation +edge case. + +## Remaining physical process-bus gates + +The next active laboratory work is separate from this read-only MMS evidence: + +- physical IEC 61850 SV transmit/capture interoperability; +- sustained ESP32-P4 SV timing/heap/TX-error evidence; +- later VLAN/priority validation subject to the selected Ethernet driver path; +- physical GOOSE transmit/subscriber interoperability. diff --git a/docs/REFERENCE_PRODUCT_ARCHITECTURE.md b/docs/REFERENCE_PRODUCT_ARCHITECTURE.md new file mode 100644 index 000000000..9a144fcc1 --- /dev/null +++ b/docs/REFERENCE_PRODUCT_ARCHITECTURE.md @@ -0,0 +1,300 @@ +# ARStack61850 reference product architecture + +This document defines the near-term reference products and the long-term architecture direction for ARStack61850. + +The project goal is not to build one board-specific demo. The goal is a smart, portable IEC 61850 stack whose protocol core, deterministic wire behavior, configuration model, QA evidence, and application-facing APIs remain reusable across desktop systems, ESP32-class MCUs, and later industrial MCU/MPU targets. + +## Design rule: one core, multiple products + +The same portable C++ protocol core must serve all reference products. + +Platform-specific code is restricted to small HAL/adaptation layers for: + +- monotonic/UTC clocks; +- raw Ethernet transmit/receive; +- TCP byte streams/listeners; +- GPIO/relay/digital-I/O access; +- persistent configuration storage; +- task/thread scheduling; +- optional USB/serial, Wi-Fi, RS485, and Ethernet management transports. + +Application code may select different protocol subsets and capacities, but it must not fork or reimplement IEC 61850 wire codecs per board. + +## Short-term reference topology + +```text + ARStack61850 PC Workbench + Windows CLI first, GUI/workbench later + | + management/control protocol + USB serial / TCP / Wi-Fi + +-----------------+-----------------+ + | | + v v + ESP32-P4 Process-Bus Injector ESP32-S3 I/O IED + Waveshare ESP32-P4-ETH Waveshare Relay-6CH + | | + wired Ethernet 6 relay outputs + | isolated RS485 + v | + IEC 61850 process bus physical I/O / sensors + SV first, then GOOSE + | + +-------> Wireshark / test IED / future S3 wired IED +``` + +The PC is the engineering/control plane. The P4 is the deterministic process-bus traffic engine. The S3 is the I/O/IED execution node. The portable C++ core remains common to all three. + +## Product A — Windows reference CLI / workbench + +### First role + +Provide a hardware-independent reference environment for the exact same stack used by embedded targets. + +Initial executable direction: + +```text +ariec61850_sv_loopback.exe +``` + +Required modes: + +1. `software` — publisher -> in-memory raw-Ethernet adapter -> decoder/verifier; +2. `pcap` — publisher -> PCAP -> reopen/independent verification; +3. `live-npcap` — later, raw Layer-2 transmit/capture through a selected Windows Ethernet adapter; +4. `device-control` — later, configure and control the P4 injector and S3 IED. + +Required test/fault modes should grow to include normal stream, drop, duplicate, corruption, late scheduling, counter wrap, rate change, dataset/payload profiles, and replayable scenarios. + +The Windows implementation is a reference/oracle and engineering tool, not a separate protocol implementation. + +## Product B — ESP32-P4 process-bus injector + +### Reference hardware + +Waveshare ESP32-P4-ETH. + +### Primary role + +A PC-controlled IEC 61850 process-bus injector/generator for laboratory, commissioning-assistance, interoperability, regression, and education use. + +### First accepted slice + +- native ESP32-P4 EMAC/RMII; +- ESP-IDF as primary platform; +- Layer-2 Sampled Values publisher; +- first profile: 50 Hz / 80 samples-per-cycle / 4,000 sample instants per second; +- deterministic synthetic 8-channel payload; +- untagged first proof; +- APPID/MAC/svID configurable at application level; +- caller-owned frame buffers; +- no publisher-generated catch-up burst; +- CI-produced flashable firmware. + +### Injector evolution + +The P4 should become a scenario-driven engine rather than a hard-coded publisher. + +Planned capabilities: + +- SV waveform/scenario profiles; +- magnitude, phase, frequency, harmonic, DC offset and transient scripting; +- COMTRADE-to-SV playback; +- configurable APPID, svID, destination MAC, VLAN and sample profile; +- controlled drop/duplicate/corrupt/jitter fault injection; +- GOOSE publisher and subscriber test modes; +- PCAP replay/compare workflows where technically appropriate; +- start/stop/arm/trigger commands from the PC; +- runtime telemetry for frame counts, encode/TX failures, missed/coalesced ticks, lateness and heap watermark; +- synchronized triggering and PTP/time synchronization only after separate timing evidence exists. + +### Control-plane rule + +The management/control plane must be separated conceptually from the process-bus data plane. + +For early development, USB serial is preferred for injector commands because it cannot consume or perturb the Ethernet process-bus path. TCP or other management transports can be added later behind the same command model. + +No control-plane transport is allowed to change IEC 61850 process-bus wire behavior in the portable core. + +## Product C — ESP32-S3 I/O IED + +### First reference hardware + +Waveshare ESP32-S3-Relay-6CH, SKU 26756. + +The board provides six relay outputs, isolated RS485, Wi-Fi/Bluetooth, USB, Pico-compatible expansion, wide-range industrial DC input, and isolation/protection around the relay/control sections. + +The current 6CH board does not provide onboard wired Ethernet. Therefore its first IEC 61850 proof is deliberately separated into two stages. + +### Stage C1 — static MMS I/O IED over IP/Wi-Fi + +Use ESP-IDF and the portable TCP/MMS/server subset to prove a real I/O-backed IED model without waiting for a wired-Ethernet expansion. + +Initial surface: + +- static compile-time IED model; +- MMS TCP server; +- TPKT/COTP/session/presentation/ACSE association; +- MMS Initiate; +- `GetNameList`; +- `GetVariableAccessAttributes`; +- `Read`; +- bounded, explicitly authorized control/write path for relay outputs; +- six physical relay channels mapped through a small static I/O data model, initially GGIO-style generic I/O unless a more specific application model is intentionally selected; +- physical output state readback where electrically/firmware-available; +- local safety defaults: outputs OFF after invalid configuration/recovery unless a documented application policy says otherwise; +- PC CLI interoperability tests and reproducible CI server simulations. + +Wi-Fi is acceptable for this functional MMS/server proof. It is not used as evidence of deterministic station-bus or protection-grade GOOSE behavior. + +### Stage C2 — wired Ethernet IED + +For station-bus/GOOSE work, add a supported wired Ethernet path through the network HAL. + +Candidate directions: + +- external SPI Ethernet controller such as W5500 if a clean, electrically suitable expansion is selected for the Relay-6CH platform; or +- a Waveshare ESP32-S3 industrial Ethernet I/O variant with onboard W5500 for the wired reference build. + +The protocol core and IED model must remain unchanged when switching from Wi-Fi/TCP proof to wired Ethernet. Only the network and board-I/O adapters should change. + +### IED evolution + +After the static read/control IED is stable: + +- DataSet support; +- URCB reporting; +- bounded GOOSE publisher/subscriber; +- command/control model refinement; +- input expansion and status/event timestamps; +- RS485/Modbus gateway points where useful; +- SCL-driven static model generation; +- BRCB after buffering/reservation semantics are proven; +- persistent configuration with versioned schema and safe rollback. + +## Shared PC-to-device control model + +The P4 injector and S3 IED should expose one versioned management model to the PC instead of ad-hoc command strings per firmware. + +The command model should be transport-independent and support at least: + +- device identity/capabilities; +- firmware/core versions; +- get/set configuration; +- start/stop/arm/trigger; +- scenario/profile selection; +- runtime statistics; +- fault injection controls where applicable; +- health/diagnostics; +- structured error responses; +- configuration export/import; +- explicit protocol-version negotiation. + +Early transport may be line-delimited JSON over USB serial for development speed. The schema should be defined independently so the same commands can later travel over TCP/WebSocket/USB without changing device behavior. + +## QA ladder + +Every feature should advance through explicit evidence levels. + +### Q0 — pure portable unit/regression + +- codecs; +- state machines; +- capacity/error paths; +- golden vectors; +- sanitizers/fuzzing where applicable. + +### Q1 — host-system simulation + +- Windows/Linux executable; +- deterministic virtual clock; +- in-memory transport; +- independent wire oracle; +- PCAP artifacts; +- injected drops/duplicates/corruption/late events. + +### Q2 — target cross-compile + +- real ESP-IDF target compiler/linker; +- board application links; +- image/footprint report; +- flash manifest produced. + +### Q3 — flashable artifact + +- complete CI artifact; +- checksums; +- bootloader/partition/app images; +- board/profile metadata; +- flashing instructions. + +### Q4 — physical functional acceptance + +- board boots; +- required interfaces operate; +- packets/associations observed by another device; +- I/O actually changes/readbacks; +- short soak accepted. + +### Q5 — physical timing/interoperability acceptance + +- sustained traffic; +- jitter/drop evidence; +- multi-vendor or independent-tool interoperability; +- long soak; +- synchronization evidence where claimed. + +No software-only or cross-compile result may be promoted to Q4/Q5 language. + +## Long-term target — smart portable IEC 61850 platform + +The long-term product is a coherent IEC 61850 platform, not a collection of protocol demos. + +Target capability families: + +### Process bus + +- SV publisher/subscriber; +- GOOSE publisher/subscriber; +- VLAN/priority; +- stream supervision; +- scenario/replay tooling; +- deterministic embedded runtimes; +- synchronization/PTP integration on hardware that can support it properly. + +### Station bus / MMS + +- client and server association; +- discovery and typed data model; +- read/write/control; +- DataSets; +- URCB/BRCB reporting; +- file service; +- robust reconnect/recovery; +- authorization/safety boundaries. + +### Engineering + +- SCL parse/validate/edit/generate; +- static embedded-model generation from SCL; +- COMTRADE integration; +- PCAP evidence/analysis; +- live discovery and parity checking; +- deterministic simulator/virtual IED; +- engineering workbench. + +### Portable runtime profiles + +- desktop Windows/Linux; +- ESP32-P4 process-bus profile; +- ESP32-S3 I/O IED profile; +- later STM32/NXP/other industrial MCU targets; +- future MPU/Linux target if large dynamic models or heavier engineering services require it. + +## Quality bar + +"Portable" means the IEC 61850 core does not know whether it runs on WinSock, lwIP, ESP-IDF, W5500, or another vendor HAL. + +"Smart" means configuration, model generation, scenario generation, diagnostics, evidence collection, and interoperability workflows reduce engineering effort while remaining deterministic and inspectable. + +"Top global" is treated as an engineering quality target, not a marketing claim. The evidence required includes clean architecture, standards-aware behavior, bounded resource use, deterministic tests, fuzz/sanitizer coverage, reproducible firmware, physical interoperability, timing measurements, documentation, long-soak results, and eventually independent/certification evidence for any conformance level that is claimed. diff --git a/docs/SV_INJECTOR_CONTROL.md b/docs/SV_INJECTOR_CONTROL.md new file mode 100644 index 000000000..fcc76e54b --- /dev/null +++ b/docs/SV_INJECTOR_CONTROL.md @@ -0,0 +1,329 @@ +# Deterministic SV injector control model + +ARStack61850 treats the Sampled Values injector as one sample-index-driven signal engine with multiple source modes, transports and execution targets. The user interface, management transport and physical scheduler are deliberately separated from waveform state. + +## Execution model + +Waveform/source state advances by integer **logical sample index**, not by UI refresh time, wall-clock elapsed time, or the number of frames a host scheduler happened to send successfully. + +```text +source configuration / program + | + v +logical sample index -----> deterministic source engine + | | + | v + | 8 x INT32 + quality + | | + v v +sample counter ----------------> SV frame encoder + | + +--------------------+--------------------+ + | | | + software loopback Windows live NIC ESP32-P4 EMAC + / PCAP reference reference path physical injector +``` + +The source engine answers **which logical sample and values** belong to a sample index. The scheduler answers **when a physical frame may be attempted**. A late scheduler must not decide which waveform sample logically comes next. + +## Control schema + +Schema identifier: + +```text +arstack-sv-injector-control-v1 +``` + +Lifecycle: + +```text +IDLE -> CONFIGURED -> ARMED -> RUNNING -> STOPPED + | + v + FAULT +``` + +Lifecycle commands: + +- `capabilities` +- `configure` +- `arm` +- `start` +- `stop` +- `status` +- `stats` + +`configure` replaces validated inactive configuration. It is rejected while `ARMED` or `RUNNING`. `arm` freezes the current configuration revision. `start` is accepted only from `ARMED`. `stop` ends an armed/running session without deleting the stored configuration. A new valid `configure` can recover from `FAULT`. + +## First deterministic profile + +- 4,000 sample instants/s +- 50 Hz nominal generated waveform +- 80 samples/cycle +- one ASDU per Ethernet frame +- `smpCnt` wrap at 4,000 +- channel order `Ia, Ib, Ic, In, Va, Vb, Vc, Vn` +- one INT32 sample plus 32-bit quality word per channel +- 64-byte `seqData` +- APPID `0x4001` +- destination `01:0C:CD:04:00:01` +- `svID = ARSTACK61850_INJECTOR` +- dataset `ARSTACK61850/LLN0$PhsMeas1` +- untagged first proof +- `smpSynch=0` until synchronization evidence exists + +The generated-waveform engine uses a fixed-point phase accumulator, fractional phase remainder, fixed Q15 sine table and integer scenario interpolation. Its steady-state generation path does not require floating-point math or heap allocation. + +## Windows reference injector + +Executable: + +```text +ariec61850_sv_injector +``` + +Software loopback examples: + +```powershell +.\ariec61850_sv_injector.exe --scenario normal --frames 8000 +.\ariec61850_sv_injector.exe --scenario protection-fault +.\ariec61850_sv_injector.exe --frames 8000 --drop-every 1000 +.\ariec61850_sv_injector.exe --frames 8000 --pcap sv-loopback.pcap +``` + +Windows live examples: + +```powershell +.\ariec61850_sv_injector.exe --list-interfaces + +.\ariec61850_sv_injector.exe ` + --mode live ` + --interface "Ethernet" ` + --scenario normal ` + --continuous +``` + +The Windows live path uses a real monotonic host clock with no catch-up burst. Logical waveform sequence remains deterministic, while physical Windows scheduling is explicitly best-effort. + +A manual lab run has already shown an independent external SV analyzer decoding the Windows live stream on a local virtual Ethernet adapter with APPID `0x4001`, 50 Hz, the canonical 4I+4V mapping and balanced 120-degree three-phase phasors. Treat this as **Windows live subscriber acceptance**, not ESP32-P4 hardware acceptance. + +## ESP32-P4 device control + +The ESP32-P4 firmware uses the same `DeterministicSvInjector` and canonical 4I+4V profile as the Windows reference path. + +The initial management transport is line-delimited JSON over USB Serial/JTAG standard input/output. It appears as a serial device on the host operating system. + +Requests are one JSON object per line. Responses are one-line records prefixed with: + +```text +ARCTRL +``` + +so a PC controller can distinguish control responses from firmware log output. + +On boot the P4 validates the normal source and waits in `CONFIGURED`. A normal run is: + +```json +{"command":"status"} +{"command":"arm"} +{"command":"start"} +``` + +The publisher retains a pending logical sample if a scheduler poll occurs before the next frame is due. This prevents an early wake-up from consuming a waveform sample without transmission. Encode failure, physical TX failure, missing injector state, or logical sample/`smpCnt` divergence transitions the device to `FAULT` rather than silently continuing with ambiguous test data. + +## Realtime manual source + +The normal source can be edited while `RUNNING` without resetting the global logical sample index or `smpCnt`. + +Sparse control examples: + +```json +{"command":"set-channel","channel":"Ia","rms":2500} +{"command":"set-channel","channel":"Va","phaseMilliDeg":15000} +{"command":"set-channel","channel":"Ib","frequencyMilliHz":49500,"quality":0} +``` + +Supported fields include: + +- enabled state +- RMS +- DC offset +- phase +- frequency +- harmonic amplitude/order +- clipping +- quality word + +The control task parses and queues typed commands. The publisher task is the single writer of the runtime source program. A sparse edit copies the active hold profile, changes only the requested fields and stages the new profile on a logical sample boundary. + +An edit is rejected as `source-busy` while a finite transition is actively in progress. This is intentional: an interruptible transition needs an explicit snapshot of the current effective interpolated state before a new target can be defined. + +## Sample-indexed ramp + +Ramp duration is expressed in logical samples, not UI milliseconds. + +```json +{"command":"ramp-channel","channel":"Ia","rms":5000,"durationSamples":4000} +``` + +At 4,000 sample instants/s, 4,000 samples is one logical second. The runtime program creates the transition and a final indefinite hold. RMS, DC, frequency, harmonic amplitude and clipping use deterministic integer interpolation. Phase target changes currently use state-boundary phase-offset behavior rather than a continuous phase-slew model. + +## Transactional state sequencer + +Finite state programs are built as a transaction so a partially uploaded program can never become active. + +Protocol flow: + +```text +sequence-begin +sequence-state-begin [step|linear] +sequence-set-channel +sequence-state-commit +... repeat state construction ... +sequence-commit +``` + +`sequence-abort` discards the draft. + +Each newly opened state inherits the previous committed draft state, so only changed channels need to be transmitted. The fixed-capacity builder validates each state before accepting it. `sequence-commit` is the only point that stages the prepared sequence into the active runtime source ring. + +A finite sequence ends in an indefinite hold of its final state. Repeat counts, external triggers and conditional branches remain higher-level orchestration features and are not silently implied by the bounded primitive. + +## Windows device-control client + +Executable: + +```text +ariec61850_sv_device_ctl +``` + +Lifecycle examples: + +```powershell +.\ariec61850_sv_device_ctl.exe --device COM7 status +.\ariec61850_sv_device_ctl.exe --device COM7 arm +.\ariec61850_sv_device_ctl.exe --device COM7 start +.\ariec61850_sv_device_ctl.exe --device COM7 stats +.\ariec61850_sv_device_ctl.exe --device COM7 stop +``` + +Realtime examples: + +```powershell +.\ariec61850_sv_device_ctl.exe --device COM7 set-channel Ia rms 2500 +.\ariec61850_sv_device_ctl.exe --device COM7 set-channel Va phase 15000 +.\ariec61850_sv_device_ctl.exe --device COM7 ramp-channel Ia rms 5000 4000 +``` + +Sequence examples: + +```powershell +.\ariec61850_sv_device_ctl.exe --device COM7 sequence-begin +.\ariec61850_sv_device_ctl.exe --device COM7 sequence-state-begin 2000 step +.\ariec61850_sv_device_ctl.exe --device COM7 sequence-set-channel Ia rms 1000 +.\ariec61850_sv_device_ctl.exe --device COM7 sequence-state-commit +.\ariec61850_sv_device_ctl.exe --device COM7 sequence-state-begin 800 linear +.\ariec61850_sv_device_ctl.exe --device COM7 sequence-set-channel Ia rms 4000 +.\ariec61850_sv_device_ctl.exe --device COM7 sequence-state-commit +.\ariec61850_sv_device_ctl.exe --device COM7 sequence-commit +``` + +`--emit-request` generates the exact JSON without opening a serial port. CI uses this path to test the public command contract on Windows and Linux. + +## Atomic grouped-edit foundation + +Interactive GUIs often need several phase/channel parameters to become active on the same sample boundary. The core therefore includes a revisioned full-profile transaction builder that can snapshot a profile, apply multiple channel edits and represent either an immediate or ramped commit target. + +This builder is covered by embedded regression tests. It is **not yet exposed as a device control command**, so grouped multi-channel edits should not be described as an active public device feature until the transactional wire commands are added. + +## Recorded-waveform replay + +Recorded waveform replay is kept separate from generated phasor sources. + +Current host pipeline: + +```text +recorded waveform + | + v +host parser + semantic channel map + | + v +ARSVRPL1 normalized replay bundle +8 x (INT32 + quality), 64 bytes/sample + | + +------> Windows live replay reference + | + v +bounded device replay-ring primitive +``` + +`ariec61850_sv_replay_prepare` parses COMTRADE input on the host, maps recognized analog channels into `Ia, Ib, Ic, In, Va, Vb, Vc, Vn`, converts supported engineering units to base A/V counts and emits an `ARSVRPL1` bundle. Missing mapped channels are zero-filled. Replay bundle v1 accepts an exact 4,000 Hz source; it does not silently resample. + +`ariec61850_sv_replay_live` validates the same bundle cross-platform and can transmit it through a selected Windows Npcap adapter. A finite run plays the bundle once; `--continuous` repeats it until interrupted. + +The bundle stores the exact 64-byte SV payload per logical sample. An independent Python oracle in CI verifies magic, header fields, expected byte count, channel order, first sample values and quality words without calling the C++ bundle decoder. + +The MCU side provides a fixed-capacity replay ring with explicit overflow and underrun counters. It is the intended boundary between bulk delivery/preload and the 4 kHz publisher task. + +At 4,000 samples/s and 64 payload bytes/sample, replay payload alone is 256,000 bytes/s before framing. The line-delimited JSON management channel is therefore **not** treated as a proven bulk replay data plane. Device replay requires a separately measured binary upload/preload transport before it can be labeled realtime-capable. + +## Built-in generated scenarios + +### `normal` + +Balanced three-phase current and voltage, neutral channels disabled, 50 Hz, held indefinitely. This is the editable manual/ramp/sequence source. + +### `protection-fault` + +A deterministic 4,800-sample generated sequence: + +1. prefault: 2,000 samples / 0.5 s +2. fault: 800 samples / 0.2 s, phase currents 4x and phase voltages 0.25x +3. recovery: 2,000 samples / 0.5 s + +The built-in scenario loops while the device remains running. Runtime manual/sequence editing is intentionally kept on the normal source path in the first implementation. + +## Physical P4 acceptance + +1. flash the CI-produced P4 firmware +2. connect USB management and Ethernet +3. query `status`; expect `CONFIGURED` +4. issue `arm`, then `start` +5. capture `eth.type == 0x88ba` with an independent receiver +6. verify APPID, `svID`, dataset, 4I+4V waveform and `smpCnt` continuity +7. change manual values while running and verify sample-boundary behavior +8. run a sample-indexed ramp and verify transition duration +9. build and commit a finite state sequence and verify its state timing +10. request `stats` and retain timer/TX/lateness evidence +11. issue `stop` and verify the stream ceases +12. repeat short, 10-minute and one-hour soak runs + +## Evidence levels + +Do not conflate these results: + +- `LOOPBACK/PASSED` — deterministic logical stream and software receive path accepted +- Windows live subscriber accepted — an independent external analyzer decoded a real live-NIC stream +- `SIMULATED/PASSED` — embedded-host behavior and independent wire oracle accepted +- `REPLAY-BUNDLE/PASSED` — normalized recorded-waveform bundle accepted by an independent oracle +- `REPLAY-LIVE/PASSED` / `REPLAY-LIVE/STOPPED` — host replay transmitted through the live Windows reference path +- `ESP32P4/CROSS-COMPILED` — ESP-IDF toolchain compiled and linked the P4 firmware +- `FLASHABLE/READY` — complete P4 firmware artifact accepted +- P4 USB control accepted — physical request/response observed on hardware +- `HARDWARE/PASSED` — physical P4 Ethernet/timing/subscriber evidence recorded + +A deterministic logical source does not by itself make Windows, FreeRTOS scheduling, Ethernet transmission, USB transport or clock synchronization deterministic. Those remain separate measurable properties. + +## Remaining source/control slices + +- expose atomic grouped multi-channel edits through the control protocol +- explicit continuous phase-slew model +- interruptible ramp based on current effective state +- repeat counts, triggers and conditional sequencer orchestration +- proven high-throughput binary replay upload/preload +- quality-state scripting beyond direct field edits +- controlled duplicate/corrupt/jitter network fault modes +- SCL-derived dynamic stream identity and payload mapping +- multi-ASDU packing +- additional IEC 61850 test modes after SV hardware acceptance diff --git a/docs/SV_INJECTOR_SOURCE_MODES.md b/docs/SV_INJECTOR_SOURCE_MODES.md new file mode 100644 index 000000000..3a2956f83 --- /dev/null +++ b/docs/SV_INJECTOR_SOURCE_MODES.md @@ -0,0 +1,147 @@ +# Sampled Values injector source modes + +The injector is designed as a sample-index-driven signal source with multiple source modes behind one common control plane. User interfaces and transports may change, but they must not become the waveform clock. + +## Core rule + +The scheduler answers **when a frame may be transmitted**. The source engine answers **which logical sample and values belong to that sample index**. + +A control update therefore never rewinds `smpCnt`, never derives waveform phase from UI refresh timing, and never sends a burst of overdue logical samples to catch up with a delayed host or task. + +## Realtime manual source + +Manual mode is the interactive source for live phasor/value adjustment while the stream is running. + +Supported channel fields are designed around the existing fixed-point signal profile: + +- enabled/disabled state; +- RMS counts; +- DC counts; +- phase in millidegrees; +- frequency in millihertz; +- harmonic amplitude in permyriad; +- harmonic order; +- clipping in permyriad; +- IEC 61850 quality word. + +The control command may contain only the fields that changed. The publisher task copies the active hold profile, applies the sparse edit, stages the new profile in the fixed-capacity runtime ring, and changes to it on a logical sample boundary. + +Example control messages: + +```json +{"command":"set-channel","channel":"Ia","rms":2500} +{"command":"set-channel","channel":"Va","phaseMilliDeg":15000} +{"command":"set-channel","channel":"Ib","frequencyMilliHz":49500,"quality":0} +``` + +The first implementation accepts realtime edits only while the runtime source is in an indefinite hold slot. This prevents an edit in the middle of a ramp from silently jumping from the ramp target instead of the current effective value. Interruptible ramps require an explicit effective-profile snapshot and remain a later extension. + +The Windows device-control CLI provides the same sparse operations: + +```text +set-channel +ramp-channel +``` + +`--emit-request` prints the exact request JSON without opening a device. CI uses this mode to regression-test the public command contract on Windows and Linux. + +## Ramp source + +Ramp mode uses the same integer sample timeline as the waveform engine. Duration is expressed in samples, not milliseconds from the UI thread. + +```json +{"command":"ramp-channel","channel":"Ia","rms":5000,"durationSamples":4000} +``` + +At 4,000 sample instants/s, a duration of 4,000 samples is one logical second. The runtime program creates: + +1. the current hold state; +2. a linear transition state with the requested sample duration; +3. an indefinite hold of the final target. + +The current implementation can ramp RMS, DC, frequency, harmonic amplitude and clipping through the deterministic engine's integer interpolation. Phase changes are applied at a state boundary through the existing phase-offset transition mechanism; a dedicated continuous phase-slew model can be added separately. + +## State sequencer + +`InjectorRuntimeProgram` provides a fixed-capacity finite state sequence primitive. Each state contains the complete eight-channel profile, a duration in logical samples, and either a step or linear transition. + +Sequence programming is transactional. Draft states do not affect the running source until the final commit succeeds: + +```text +sequence-begin +sequence-state-begin [step|linear] +sequence-set-channel +sequence-state-commit +... repeat state construction ... +sequence-commit +``` + +`sequence-abort` discards a draft. Each newly opened state inherits the previous committed draft state, so only changed channels need to be edited. The builder is fixed-capacity and validates the state before activation. + +A finite program ends in an indefinite hold of the final state. This is deliberate: a malformed or incomplete upload must not accidentally create an endless test. Repeat counts, conditional transitions and external trigger policies belong to a higher orchestration layer and can be added without changing the bounded state primitive. + +The ESP32-P4 control path implements this transaction inside the publisher-task ownership boundary. `sequence-commit` is the only operation that stages the prepared program into the active runtime ring. + +## Recorded waveform replay + +Recorded waveform replay is a different source type from generated phasors. File parsing does not belong inside the realtime publisher task. + +The implemented first replay pipeline is: + +```text +COMTRADE record + | + v +host parser + semantic channel map + | + v +ARSVRPL1 normalized replay bundle +8 x (INT32 + quality), 64 bytes/sample + | + +----> Windows live replay oracle + | + v +bounded device replay ring + | + v +future proven bulk upload/preload transport +``` + +`ariec61850_sv_replay_prepare` loads a COMTRADE record, maps recognized analog channels into the canonical `Ia, Ib, Ic, In, Va, Vb, Vc, Vn` order, converts supported engineering units to base A/V counts, writes quality zero for the first slice, and emits an `ARSVRPL1` bundle. Missing mapped channels are zero-filled. + +Replay bundle v1 intentionally accepts only an exact 4,000 Hz source. It does not silently resample a recording. A future resampler must be a separately tested host-side transformation with explicit policy and evidence. + +The bundle header is fixed and portable. Each following frame contains the exact 64-byte `seqOfData` payload that can be copied into the SV ASDU without host-endian reinterpretation. + +`ariec61850_sv_replay_live` validates the bundle on Windows or Linux and, on Windows with Npcap, can replay it onto a selected Ethernet adapter at the same best-effort 4 kHz live pacing used by the reference publisher. `--continuous` repeats the recorded sample sequence until `Ctrl+C`. + +A separate Python oracle in CI validates the bundle magic, header, byte count, channel order, first payload values and quality words without using the C++ bundle decoder. + +The MCU side has a fixed-capacity `InjectorReplayRing` with explicit overflow and underrun counters. The ring is the intended boundary between bulk delivery/preload and the 4 kHz publisher task. + +At 4,000 samples/s and 64 payload bytes/sample, the replay payload alone is 256,000 bytes/s before transport framing. The existing line-delimited control protocol is therefore kept for commands and configuration; it is not treated as a proven bulk replay data plane. A high-throughput binary upload/preload transport must be measured before device replay is labeled realtime-capable. + +## Future source families + +The same source boundary is intended to support additional modes without changing the SV encoder: + +- multi-channel manual phasors; +- multi-channel ramps and sweeps; +- finite and repeating state sequences; +- recorded waveform replay; +- harmonic/DC/clipping profiles; +- scripted quality changes; +- controlled network fault scenarios; +- SCL-derived stream identity and dataset mapping. + +Every mode must preserve the same separation between logical sample generation, physical scheduling and transport evidence. + +## Runtime safety model + +Runtime source changes are single-writer operations inside the publisher task. The control task only parses input and queues typed commands. This avoids concurrent mutation of waveform state. + +The fixed-capacity runtime ring and sequence builder are allocated outside the high-priority publisher task stack. No manual, ramp or sequence edit requires steady-state heap growth. + +Replay buffering is separately bounded and reports overflow/underrun rather than hiding missing samples. + +If source state, logical sample index or publisher `smpCnt` diverge, the device should fail closed into a reported fault instead of continuing with ambiguous test data. diff --git a/embedded/CMakeLists.txt b/embedded/CMakeLists.txt index ee0dddb9e..e3819c2fa 100644 --- a/embedded/CMakeLists.txt +++ b/embedded/CMakeLists.txt @@ -92,8 +92,79 @@ target_compile_definitions(ariec61850_embedded_link_smoke PRIVATE ARIEC61850_HOST_SERVICES=0) ariec61850_apply_embedded_flags(ariec61850_embedded_link_smoke) +# Exercise the allocation-bounded SV publisher and its no-catch-up pacing policy +# against a fake RawEthernetPort using the same embedded core library. +add_executable(ariec61850_embedded_sv_publisher_smoke + ${CMAKE_CURRENT_LIST_DIR}/sv_publisher_smoke.cpp) +target_link_libraries(ariec61850_embedded_sv_publisher_smoke PRIVATE ARIEC61850::embedded_core) +target_compile_features(ariec61850_embedded_sv_publisher_smoke PRIVATE cxx_std_20) +target_include_directories(ariec61850_embedded_sv_publisher_smoke PRIVATE ${ARIEC61850_ROOT}/include) +target_compile_definitions(ariec61850_embedded_sv_publisher_smoke PRIVATE + ARIEC61850_EMBEDDED_PROFILE=1 + ARIEC61850_HOST_SERVICES=0) +ariec61850_apply_embedded_flags(ariec61850_embedded_sv_publisher_smoke) + +# Keep the deterministic waveform/scenario engine inside the same MCU boundary. +# This catches accidental host-service or floating-point-library dependencies +# before the engine is integrated into the ESP32-P4 firmware application. +add_executable(ariec61850_embedded_sv_injector_smoke + ${CMAKE_CURRENT_LIST_DIR}/sv_injector_smoke.cpp) +target_link_libraries(ariec61850_embedded_sv_injector_smoke PRIVATE ARIEC61850::embedded_core) +target_compile_features(ariec61850_embedded_sv_injector_smoke PRIVATE cxx_std_20) +target_include_directories(ariec61850_embedded_sv_injector_smoke PRIVATE ${ARIEC61850_ROOT}/include) +target_compile_definitions(ariec61850_embedded_sv_injector_smoke PRIVATE + ARIEC61850_EMBEDDED_PROFILE=1 + ARIEC61850_HOST_SERVICES=0) +ariec61850_apply_embedded_flags(ariec61850_embedded_sv_injector_smoke) + +# Exercise the transport-independent configure/arm/start/stop/fault lifecycle +# under the same MCU warning/RTTI boundary before USB-serial transport is added. +add_executable(ariec61850_embedded_sv_injector_controller_smoke + ${CMAKE_CURRENT_LIST_DIR}/sv_injector_controller_smoke.cpp) +target_link_libraries(ariec61850_embedded_sv_injector_controller_smoke PRIVATE ARIEC61850::embedded_core) +target_compile_features(ariec61850_embedded_sv_injector_controller_smoke PRIVATE cxx_std_20) +target_include_directories(ariec61850_embedded_sv_injector_controller_smoke PRIVATE ${ARIEC61850_ROOT}/include) +target_compile_definitions(ariec61850_embedded_sv_injector_controller_smoke PRIVATE + ARIEC61850_EMBEDDED_PROFILE=1 + ARIEC61850_HOST_SERVICES=0) +ariec61850_apply_embedded_flags(ariec61850_embedded_sv_injector_controller_smoke) + +# Realtime source-program smoke: hot manual edits, sample-indexed ramps, and +# finite state sequences must preserve the logical sample timeline and remain +# inside the allocation-bounded MCU profile. +add_executable(ariec61850_embedded_sv_runtime_program_smoke + ${CMAKE_CURRENT_LIST_DIR}/sv_runtime_program_smoke.cpp) +target_link_libraries(ariec61850_embedded_sv_runtime_program_smoke PRIVATE ARIEC61850::embedded_core) +target_compile_features(ariec61850_embedded_sv_runtime_program_smoke PRIVATE cxx_std_20) +target_include_directories(ariec61850_embedded_sv_runtime_program_smoke PRIVATE ${ARIEC61850_ROOT}/include) +target_compile_definitions(ariec61850_embedded_sv_runtime_program_smoke PRIVATE + ARIEC61850_EMBEDDED_PROFILE=1 + ARIEC61850_HOST_SERVICES=0) +ariec61850_apply_embedded_flags(ariec61850_embedded_sv_runtime_program_smoke) + +# Produce a deterministic two-second classic-PCAP capture using only the +# publisher/encoder path. A separate Python implementation verifies the complete +# wire image byte-for-byte in CI, so encoder bugs cannot be hidden by the C++ +# decoder accepting the same mistake. +add_executable(ariec61850_embedded_sv_stream_pcap + ${CMAKE_CURRENT_LIST_DIR}/sv_stream_pcap.cpp) +target_link_libraries(ariec61850_embedded_sv_stream_pcap PRIVATE ARIEC61850::embedded_core) +target_compile_features(ariec61850_embedded_sv_stream_pcap PRIVATE cxx_std_20) +target_include_directories(ariec61850_embedded_sv_stream_pcap PRIVATE ${ARIEC61850_ROOT}/include) +target_compile_definitions(ariec61850_embedded_sv_stream_pcap PRIVATE + ARIEC61850_EMBEDDED_PROFILE=1 + ARIEC61850_HOST_SERVICES=0) +ariec61850_apply_embedded_flags(ariec61850_embedded_sv_stream_pcap) + enable_testing() add_test(NAME ariec61850_embedded_link_smoke COMMAND ariec61850_embedded_link_smoke) +add_test(NAME ariec61850_embedded_sv_publisher_smoke COMMAND ariec61850_embedded_sv_publisher_smoke) +add_test(NAME ariec61850_embedded_sv_injector_smoke COMMAND ariec61850_embedded_sv_injector_smoke) +add_test(NAME ariec61850_embedded_sv_injector_controller_smoke COMMAND ariec61850_embedded_sv_injector_controller_smoke) +add_test(NAME ariec61850_embedded_sv_runtime_program_smoke COMMAND ariec61850_embedded_sv_runtime_program_smoke) +add_test( + NAME ariec61850_embedded_sv_stream_pcap + COMMAND ariec61850_embedded_sv_stream_pcap "${CMAKE_BINARY_DIR}/sv_trial_2s.pcap") message(STATUS "arstack61850 embedded profile: host services excluded") message(STATUS "arstack61850 embedded profile: RTTI disabled=${ARIEC61850_EMBEDDED_NO_RTTI}") diff --git a/embedded/ROADMAP.md b/embedded/ROADMAP.md index 38acf028d..38dffb0dc 100644 --- a/embedded/ROADMAP.md +++ b/embedded/ROADMAP.md @@ -1,74 +1,151 @@ # ESP32-first embedded roadmap -The embedded roadmap is product-driven. The first hardware proof should be small enough to finish and measure. +The embedded roadmap is product-driven. The first hardware proofs are deliberately small, measurable, and reusable as foundations for a portable IEC 61850 product family. + +See [`docs/REFERENCE_PRODUCT_ARCHITECTURE.md`](../docs/REFERENCE_PRODUCT_ARCHITECTURE.md) for the reference-product topology and long-term architecture. + +## Reference products + +Short-term roles are fixed as: + +- **Windows PC** — reference CLI/workbench, loopback QA, scenario/configuration controller, evidence collection; +- **ESP32-P4 / Waveshare ESP32-P4-ETH** — IEC 61850 process-bus injector, SV first and GOOSE next; +- **ESP32-S3 / Waveshare ESP32-S3-Relay-6CH** — static I/O IED, six physical relay outputs and isolated RS485, MMS/IP first and wired station-bus/GOOSE after a supported Ethernet path is added. + +The protocol core must remain common across these products. Board-specific code belongs only in HAL/platform/application adapters. ## E0 — portability boundary -Status: in progress. +Status: accepted for the current embedded slice, with hard-profile work still remaining in E5. -Acceptance: +Accepted: - standalone `embedded/` CMake build; - GCC and Clang host-sim compile; -- RTTI disabled; -- host TCP/PCAP/COMTRADE/SCL/CLI dependencies rejected by CI; -- all embedded objects participate in a full-link smoke executable; -- existing desktop C++ CI remains unchanged and green. +- RTTI disabled in the embedded profile; +- host TCP/filesystem/capture services excluded from the first MCU component boundary; +- all selected embedded objects participate in a full-link smoke executable; +- desktop C++ CI remains independent; +- platform-neutral raw-Ethernet/clock abstraction exists; +- ESP-IDF component boundary exists. -Not yet accepted: +Still deferred to the hard embedded profile: -- `-fno-exceptions`; -- deterministic/no-heap wire encoding; -- ESP-IDF compilation; -- real ESP32 Ethernet traffic. +- global `-fno-exceptions` acceptance; +- removal/splitting of remaining exception-dependent convenience/decode APIs from the MCU slice; +- explicit footprint budgets for every future embedded protocol subset. ## E1 — allocation-bounded SV wire path -This is the next protocol refactor because it directly enables the smallest useful ESP32 product. +Status: accepted for the first-trial publisher slice. + +Accepted: + +- caller-owned Ethernet frame buffer; +- allocation-bounded steady-state SV encode/transmit path; +- no exception propagation from the publisher hot path; +- existing host API retained; +- VLAN/non-VLAN framing remains explicit; +- sample counter wrap behavior tested; +- deterministic 8,000-frame / two-second simulation at 4 kHz; +- exact-byte independent Python PCAP oracle; +- GCC + Clang embedded-host simulation evidence retained by CI. + +Semantic follow-up before mature process-bus acceptance: -Target API direction: +- finalize and document `smpCnt` progression when a transmit fails or nominal sample instants are intentionally skipped after scheduler lateness. -```cpp -EncodeResult encode_into( - const SampledValuesFrame& frame, - std::span destination) noexcept; +## E2 — Windows SV loopback reference CLI + +Status: next implementation slice. + +Initial executable direction: + +```text +ariec61850_sv_loopback.exe ``` -Acceptance: +Required first modes: -- caller-owned Ethernet frame buffer; -- no heap allocation on the steady-state SV encode/transmit path; -- no exceptions on that path; -- byte-for-byte parity with existing C++/ARIEC61850 golden SV vectors; -- existing `std::vector` host API retained as a compatibility wrapper; -- VLAN/non-VLAN framing remains explicit; -- sample counter wrap behavior remains tested. +1. `software` — `SampledValuesPublisher` -> in-memory `RawEthernetPort` -> decoder/verifier; +2. `pcap` — generated stream -> PCAP -> reopen/independent verification; +3. machine-readable JSON summary plus human console output. + +Fault/regression modes should then cover: + +- drop; +- duplicate; +- corrupt; +- late poll/tick; +- counter wrap; +- rate/profile change. + +Later add `live-npcap` raw Layer-2 transmit/capture through a selected Windows Ethernet adapter. + +This CLI becomes both a pre-hardware QA tool and the desktop behavioral reference against which the ESP32-P4 injector is compared. + +## E3 — ESP32-P4 SV injector MVP -## E2 — ESP32 SV Publisher MVP +Status: software/cross-compile/flashable gates accepted; physical Ethernet acceptance pending. Platform layer: -- ESP-IDF/FreeRTOS owns task scheduling and network driver; -- `RawEthernetPort` binds to the platform raw Ethernet TX function; -- fixed frame buffers are owned by the application; -- sample source is pluggable (synthetic first, ADC later). +- ESP-IDF/FreeRTOS owns task scheduling and Ethernet driver; +- `RawEthernetPort` binds to `esp_eth_transmit()`; +- fixed frame buffers are application-owned; +- sample source is pluggable; +- management plane is conceptually separated from the process-bus data plane. -Minimum demonstration: +Accepted before hardware: + +- real ESP32-P4 ESP-IDF v6.0.2 cross-compile/link; +- flashable firmware image produced by CI; +- ESP-IDF flasher manifest targets `esp32p4`; +- bootloader, partition table, application image, ELF/map, checksums and build-profile evidence retained; +- first synthetic SV profile is reproducible by host simulation. + +Physical minimum demonstration: 1. configure destination MAC, APPID and svID; -2. generate deterministic sampled values; -3. publish valid IEC 61850 SV Layer-2 frames; -4. decode them with the desktop arstack61850 decoder and an independent IEC 61850 tool; -5. capture PCAP and compare payload fields against the host golden path; -6. run a sustained-rate test appropriate to the selected SV profile. +2. initialize native ESP32-P4 EMAC/RMII and PHY; +3. generate deterministic sampled values; +4. publish valid IEC 61850 SV Layer-2 frames; +5. decode them with Wireshark/desktop tools; +6. capture PCAP and compare fields against the host golden path; +7. measure sustained 4,000-frame/s behavior, TX failures, missed/coalesced ticks, lateness, and heap watermark; +8. short, 10-minute, then one-hour soak. + +A common 50 Hz / 80 samples-per-cycle profile implies 4,000 sample instants per second. That is the first stress target, not a blanket standards claim for every SV application. + +### P4 injector evolution + +After physical SV acceptance: + +- PC-controlled start/stop/arm/trigger; +- versioned configuration/scenario protocol, USB serial first; +- configurable SV identities/profile/VLAN; +- waveform and transient scenarios; +- COMTRADE-to-SV playback; +- controlled drop/duplicate/corrupt/jitter injection; +- GOOSE publisher/subscriber laboratory modes; +- PCAP compare/replay workflows; +- synchronized triggering and PTP only after separate hardware/timing evidence exists. -A common 50 Hz / 80 samples-per-cycle profile implies 4,000 sample instants per second. That is a useful stress target, not a blanket standards claim for every SV application. +## E4 — ESP32-S3 I/O IED MVP -Timing quality must be reported honestly. A lab ESP32 publisher is not called protection-grade or time-synchronized until jitter and synchronization/PTP evidence support that claim. +Status: planned after the Windows loopback slice and P4 first physical proof are stable enough to serve as test peers. -## E3 — simple ESP32 IEC 61850 I/O IED +First hardware: -Start with a deliberately static device model. +- Waveshare ESP32-S3-Relay-6CH, SKU 26756; +- six physical relay outputs; +- isolated RS485; +- Wi-Fi/Bluetooth/USB management capability; +- industrial DC input and isolation/protection hardware. + +The Relay-6CH board has no onboard wired Ethernet, so development is split deliberately. + +### E4.1 — MMS/static IED over IP/Wi-Fi Initial server surface: @@ -78,32 +155,83 @@ Initial server surface: - `GetVariableAccessAttributes`; - `Read`; - compile-time/static Logical Device / Logical Node / Data Object table; -- GPIO-backed SPS/DPS/measurement points. +- six relay outputs exposed through a small static GGIO-style I/O model; +- bounded, explicitly authorized relay control/write path; +- safe startup/recovery output policy; +- PC CLI interoperability and host-sim regression. + +Wi-Fi proves MMS/application behavior but is not evidence for deterministic station-bus or protection-grade GOOSE. + +### E4.2 — wired Ethernet IED + +Add a supported wired network adapter behind the same portable HAL, for example a suitable SPI Ethernet controller, or use a Waveshare S3 industrial Ethernet I/O variant with onboard W5500 as the wired reference platform. Then add, in controlled slices: -- bounded `Write` for configuration points where appropriate; -- control model for outputs; - one small static DataSet; -- URCB reporting first; +- URCB reporting; +- bounded GOOSE publisher/subscriber; +- refined control model; +- input/status/event timestamp expansion; +- RS485/Modbus gateway points where useful; +- SCL-driven static model generation; - BRCB only after reservation/buffering semantics are proven. -Dynamic DataSet and large live-discovery structures are not initial MCU requirements. +Dynamic DataSet and large dynamic discovery structures are not initial MCU requirements. -## E4 — hard embedded profile +## E5 — shared PC-to-device control plane + +Status: planned. + +The P4 injector and S3 IED must not invent unrelated ad-hoc command protocols. + +Define one versioned, transport-independent management schema for: + +- identity and capabilities; +- stack/firmware version; +- configuration get/set/export/import; +- start/stop/arm/trigger; +- scenario/profile selection; +- runtime statistics and health; +- explicit structured errors; +- fault injection where applicable; +- protocol-version negotiation. + +Early development transport may be line-delimited JSON over USB serial. Later transports may include TCP/WebSocket without changing command semantics. + +## E6 — hard embedded profile Acceptance: -- `-fno-exceptions` enabled in CI; +- `-fno-exceptions` enabled for the intended embedded protocol slices; - `-fno-rtti` remains enabled; - no uncontrolled heap use in protocol steady state; - explicit compile-time capacities; -- platform clock/TCP/raw-Ethernet HAL only; -- ESP-IDF build in CI or reproducible local toolchain build; -- RAM/flash footprint report. +- platform clock/TCP/raw-Ethernet/I/O HAL only; +- ESP-IDF target builds in CI; +- per-profile RAM/flash/stack footprint reports; +- long-soak resource stability; +- fault/recovery behavior documented and regression-tested. + +## E7 — industrial portability targets + +After ESP32 proofs are stable, port only HAL/platform integration to additional hardware. + +Candidate families include STM32, NXP and other industrial MCU/MPU platforms selected according to Ethernet MAC/DMA capability, timer determinism, memory, isolation, watchdog/recovery, synchronization/PTP requirements, lifecycle, and toolchain quality. + +For higher-end protection/process-bus ambitions, hardware is selected to satisfy the timing/conformance goal. The IEC 61850 core must not be rewritten around a vendor SDK. + +## Long-term outcome -## E5 — STM32/NXP industrial target +ARStack61850 should converge into one coherent, smart, portable IEC 61850 platform spanning: -After the ESP32 proof is stable, port only the HAL and platform integration. The protocol core must remain unchanged. +- SV and GOOSE process bus; +- MMS client/server, discovery, reporting, control and file service; +- SCL engineering and static embedded-model generation; +- COMTRADE and PCAP workflows; +- deterministic simulator/virtual IED; +- Windows/Linux engineering workbench; +- constrained MCU profiles and larger industrial targets; +- reproducible QA from host simulation through physical interoperability and timing evidence. -For higher-end protection/process-bus ambitions, select hardware based on Ethernet MAC/DMA, timer determinism, memory, and synchronization/PTP requirements rather than rewriting the stack around a vendor SDK. +"Top global" is treated as an engineering quality target: clean architecture, standards-aware behavior, deterministic tests, bounded resources, fuzz/sanitizer coverage, reproducible artifacts, physical interoperability, timing evidence, long-soak results, clear documentation, and independent/certification evidence for any conformance level that is eventually claimed. diff --git a/embedded/association_hard_profile_smoke.cpp b/embedded/association_hard_profile_smoke.cpp new file mode 100644 index 000000000..558234eaf --- /dev/null +++ b/embedded/association_hard_profile_smoke.cpp @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/acse/association_span.hpp" +#include "ariec61850/osi/presentation_span.hpp" +#include "ariec61850/osi/session_span.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kRequest{ + 0x0DU, 0xB6U, 0x05U, 0x06U, 0x13U, 0x01U, 0x00U, 0x16U, 0x01U, 0x02U, 0x14U, 0x02U, + 0x00U, 0x02U, 0x33U, 0x02U, 0x00U, 0x01U, 0x34U, 0x02U, 0x00U, 0x01U, 0xC1U, 0xA0U, + 0x31U, 0x81U, 0x9DU, 0xA0U, 0x03U, 0x80U, 0x01U, 0x01U, 0xA2U, 0x81U, 0x95U, 0x81U, + 0x04U, 0x00U, 0x00U, 0x00U, 0x01U, 0x82U, 0x04U, 0x00U, 0x00U, 0x00U, 0x01U, 0xA4U, + 0x23U, 0x30U, 0x0FU, 0x02U, 0x01U, 0x01U, 0x06U, 0x04U, 0x52U, 0x01U, 0x00U, 0x01U, + 0x30U, 0x04U, 0x06U, 0x02U, 0x51U, 0x01U, 0x30U, 0x10U, 0x02U, 0x01U, 0x03U, 0x06U, + 0x05U, 0x28U, 0xCAU, 0x22U, 0x02U, 0x01U, 0x30U, 0x04U, 0x06U, 0x02U, 0x51U, 0x01U, + 0x61U, 0x62U, 0x30U, 0x60U, 0x02U, 0x01U, 0x01U, 0xA0U, 0x5BU, 0x60U, 0x59U, 0xA1U, + 0x07U, 0x06U, 0x05U, 0x28U, 0xCAU, 0x22U, 0x02U, 0x03U, 0xA2U, 0x07U, 0x06U, 0x05U, + 0x29U, 0x01U, 0x87U, 0x67U, 0x01U, 0xA3U, 0x03U, 0x02U, 0x01U, 0x0CU, 0xA6U, 0x06U, + 0x06U, 0x04U, 0x29U, 0x01U, 0x87U, 0x67U, 0xA7U, 0x03U, 0x02U, 0x01U, 0x0CU, 0xBEU, + 0x33U, 0x28U, 0x31U, 0x06U, 0x02U, 0x51U, 0x01U, 0x02U, 0x01U, 0x03U, 0xA0U, 0x28U, + 0xA8U, 0x26U, 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, 0x81U, 0x01U, 0x0AU, 0x82U, 0x01U, + 0x0AU, 0x83U, 0x01U, 0x05U, 0xA4U, 0x16U, 0x80U, 0x01U, 0x01U, 0x81U, 0x03U, 0x05U, + 0xF1U, 0x00U, 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, 0x08U, 0x00U, + 0x00U, 0x79U, 0xEFU, 0x18U}; + +constexpr std::array kResponse{ + 0x0EU, 0x88U, 0x05U, 0x06U, 0x13U, 0x01U, 0x00U, 0x16U, 0x01U, 0x02U, 0x14U, 0x02U, + 0x00U, 0x02U, 0x33U, 0x02U, 0x00U, 0x01U, 0x34U, 0x02U, 0x00U, 0x01U, 0xC1U, 0x72U, + 0x31U, 0x70U, 0xA0U, 0x03U, 0x80U, 0x01U, 0x01U, 0xA2U, 0x69U, 0xA5U, 0x12U, 0x30U, + 0x07U, 0x80U, 0x01U, 0x00U, 0x81U, 0x02U, 0x51U, 0x01U, 0x30U, 0x07U, 0x80U, 0x01U, + 0x00U, 0x81U, 0x02U, 0x51U, 0x01U, 0x61U, 0x53U, 0x30U, 0x51U, 0x02U, 0x01U, 0x01U, + 0xA0U, 0x4CU, 0x61U, 0x4AU, 0xA1U, 0x07U, 0x06U, 0x05U, 0x28U, 0xCAU, 0x22U, 0x02U, + 0x03U, 0xA2U, 0x03U, 0x02U, 0x01U, 0x00U, 0xA3U, 0x05U, 0xA1U, 0x03U, 0x02U, 0x01U, + 0x00U, 0xBEU, 0x33U, 0x28U, 0x31U, 0x06U, 0x02U, 0x51U, 0x01U, 0x02U, 0x01U, 0x03U, + 0xA0U, 0x28U, 0xA9U, 0x26U, 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, 0x81U, 0x01U, 0x0AU, + 0x82U, 0x01U, 0x0AU, 0x83U, 0x01U, 0x05U, 0xA4U, 0x16U, 0x80U, 0x01U, 0x01U, 0x81U, + 0x03U, 0x05U, 0xF1U, 0x00U, 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, + 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U}; + +constexpr std::array kMmsApplicationContext{ + 0x28U, 0xCAU, 0x22U, 0x02U, 0x03U}; +constexpr std::array kCalledApTitle{ + 0x29U, 0x01U, 0x87U, 0x67U, 0x01U}; +constexpr std::array kCallingApTitle{ + 0x29U, 0x01U, 0x87U, 0x67U}; +constexpr std::array kBerSyntax{0x51U, 0x01U}; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +} // namespace + +int main() { + acse::AssociationRequestView request; + if (!acse::AcseSpanCodec::try_decode_association_request_view(kRequest, request)) { + return 1; + } + + if (request.session.kind != osi::SessionWireKind::connect || + request.presentation.mode_selector != 1U || + request.presentation.context_count != 2U || + request.acse_presentation_context_id != 1U || + request.mms_presentation_context_id != 3U || + !matches(request.aarq.application_context_name, kMmsApplicationContext) || + !matches(request.aarq.called_ap_title, kCalledApTitle) || + !request.aarq.called_ae_qualifier.has_value() || + *request.aarq.called_ae_qualifier != 12U || + !matches(request.aarq.calling_ap_title, kCallingApTitle) || + !request.aarq.calling_ae_qualifier.has_value() || + *request.aarq.calling_ae_qualifier != 12U || + !matches(request.aarq.user_information.direct_reference, kBerSyntax) || + request.aarq.user_information.indirect_reference != 3U || + !matches( + request.aarq.user_information.single_asn1_type, + std::array{ + 0xA8U, 0x26U, + 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, + 0x81U, 0x01U, 0x0AU, + 0x82U, 0x01U, 0x0AU, + 0x83U, 0x01U, 0x05U, + 0xA4U, 0x16U, + 0x80U, 0x01U, 0x01U, + 0x81U, 0x03U, 0x05U, 0xF1U, 0x00U, + 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, + 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U})) { + return 2; + } + + std::uint32_t acse_context{}; + std::uint32_t mms_context{}; + if (!request.presentation.try_context_id_for_abstract_syntax( + osi::PresentationSpanCodec::acse_abstract_syntax_name(), acse_context) || + !request.presentation.try_context_id_for_abstract_syntax( + osi::PresentationSpanCodec::mms_abstract_syntax_name(), mms_context) || + acse_context != 1U || mms_context != 3U) { + return 3; + } + + std::array response{}; + const auto required = acse::AcseSpanCodec::accept_response_size(request); + const auto result = acse::AcseSpanCodec::build_accept_response_into(request, response); + if (!required || *required != kResponse.size() || + !result.success() || result.bytes_written != kResponse.size() || + !matches( + std::span{response}.first(result.bytes_written), + kResponse)) { + return 4; + } + + // Confirm the deterministic AARE itself is stable and contains the expected + // MMS Initiate response used by the host association profile. + std::array aare{}; + const auto aare_result = acse::AcseSpanCodec::encode_default_accept_aare_into(aare); + if (!aare_result.success() || + aare_result.bytes_written != acse::AcseSpanCodec::default_accept_aare_size() || + !matches( + acse::AcseSpanCodec::default_mms_initiate_response(), + std::array{ + 0xA9U, 0x26U, + 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, + 0x81U, 0x01U, 0x0AU, + 0x82U, 0x01U, 0x0AU, + 0x83U, 0x01U, 0x05U, + 0xA4U, 0x16U, + 0x80U, 0x01U, 0x01U, + 0x81U, 0x03U, 0x05U, 0xF1U, 0x00U, + 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, + 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U})) { + return 5; + } + + // Association setup is not a realtime hot path, but repeated construction + // still proves the bounded responder does not require owning protocol trees. + for (std::uint32_t iteration = 0U; iteration < 10'000U; ++iteration) { + const auto repeated = acse::AcseSpanCodec::build_accept_response_into( + request, response); + if (!repeated.success() || repeated.bytes_written != kResponse.size() || + response[0] != osi::SessionSpanCodec::accept_code || + response[1] != 0x88U) { + return 6; + } + } + + constexpr std::array malformed{ + 0x0DU, 0x02U, 0xC1U, 0x00U}; + acse::AssociationRequestView rejected; + if (acse::AcseSpanCodec::try_decode_association_request_view(malformed, rejected)) { + return 7; + } + + return 0; +} diff --git a/embedded/brcb_hard_profile/CMakeLists.txt b/embedded/brcb_hard_profile/CMakeLists.txt new file mode 100644 index 000000000..79d1052b5 --- /dev/null +++ b/embedded/brcb_hard_profile/CMakeLists.txt @@ -0,0 +1,94 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARIEC61850BrcbHardProfile + VERSION 0.2.0 + DESCRIPTION "No-exception bounded IEC 61850 buffered-report profile" + LANGUAGES CXX) + +set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") + +if(MSVC) + message(FATAL_ERROR "BRCB hard profile is validated with GCC/Clang -fno-exceptions.") +endif() + +function(ariec61850_configure_brcb_hard_target target) + target_compile_features(${target} PRIVATE cxx_std_20) + target_include_directories(${target} PRIVATE ${ARIEC61850_ROOT}/include) + target_compile_definitions(${target} PRIVATE + ARIEC61850_EMBEDDED_PROFILE=1 + ARIEC61850_HOST_SERVICES=0 + ARIEC61850_NO_EXCEPTIONS=1) + target_compile_options(${target} PRIVATE + -Wall + -Wextra + -Wpedantic + -Wconversion + -Wsign-conversion + -Werror + -fno-exceptions + -fno-rtti) +endfunction() + +add_executable(ariec61850_hard_mms_brcb_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_brcb_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp + ${ARIEC61850_ROOT}/src/mms/pdu_span.cpp + ${ARIEC61850_ROOT}/src/mms/services_span.cpp + ${ARIEC61850_ROOT}/src/mms/information_report_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_object_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_data_set_table.cpp + ${ARIEC61850_ROOT}/src/mms/buffered_selective_information_report_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_brcb_runtime.cpp) +ariec61850_configure_brcb_hard_target(ariec61850_hard_mms_brcb_smoke) + +add_executable(ariec61850_hard_mms_brcb_connection_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_brcb_connection_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp + ${ARIEC61850_ROOT}/src/osi/tpkt_span.cpp + ${ARIEC61850_ROOT}/src/osi/cotp_span.cpp + ${ARIEC61850_ROOT}/src/osi/session_span.cpp + ${ARIEC61850_ROOT}/src/osi/presentation_span.cpp + ${ARIEC61850_ROOT}/src/acse/association_span.cpp + ${ARIEC61850_ROOT}/src/mms/pdu_span.cpp + ${ARIEC61850_ROOT}/src/mms/services_span.cpp + ${ARIEC61850_ROOT}/src/mms/write_span.cpp + ${ARIEC61850_ROOT}/src/mms/data_set_span.cpp + ${ARIEC61850_ROOT}/src/mms/information_report_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_object_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_write.cpp + ${ARIEC61850_ROOT}/src/mms/static_data_set_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_dispatcher.cpp + ${ARIEC61850_ROOT}/src/mms/connection_runtime.cpp + ${ARIEC61850_ROOT}/src/mms/buffered_selective_information_report_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_brcb_runtime.cpp + ${ARIEC61850_ROOT}/src/mms/static_brcb_connection.cpp) +ariec61850_configure_brcb_hard_target(ariec61850_hard_mms_brcb_connection_smoke) + +add_executable(ariec61850_hard_mms_brcb_persistence_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_brcb_persistence_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp + ${ARIEC61850_ROOT}/src/mms/pdu_span.cpp + ${ARIEC61850_ROOT}/src/mms/services_span.cpp + ${ARIEC61850_ROOT}/src/mms/information_report_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_object_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_data_set_table.cpp + ${ARIEC61850_ROOT}/src/mms/buffered_selective_information_report_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_brcb_runtime.cpp + ${ARIEC61850_ROOT}/src/mms/static_brcb_state_codec.cpp + ${ARIEC61850_ROOT}/src/mms/static_brcb_checkpoint_store.cpp) +ariec61850_configure_brcb_hard_target(ariec61850_hard_mms_brcb_persistence_smoke) + +enable_testing() +add_test(NAME ariec61850_hard_mms_brcb_smoke COMMAND ariec61850_hard_mms_brcb_smoke) +add_test( + NAME ariec61850_hard_mms_brcb_connection_smoke + COMMAND ariec61850_hard_mms_brcb_connection_smoke) +add_test( + NAME ariec61850_hard_mms_brcb_persistence_smoke + COMMAND ariec61850_hard_mms_brcb_persistence_smoke) + +message(STATUS "arstack61850 BRCB hard profile: bounded RAM queue + EntryID + BufOvfl=ON") +message(STATUS "arstack61850 BRCB hard profile: retry-safe reconnect delivery=ON") +message(STATUS "arstack61850 BRCB hard profile: dual-bank checkpoint + reboot recovery oracle=ON") +message(STATUS "arstack61850 BRCB hard profile: platform non-volatile adapter=NOT_CLAIMED") +message(STATUS "arstack61850 BRCB hard profile: exceptions/RTTI disabled=ON") diff --git a/embedded/goose_hard_profile_smoke.cpp b/embedded/goose_hard_profile_smoke.cpp new file mode 100644 index 000000000..ca54347e2 --- /dev/null +++ b/embedded/goose_hard_profile_smoke.cpp @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/goose/frame_codec.hpp" +#include "ariec61850/goose/pdu_codec.hpp" +#include "ariec61850/mms/data_value.hpp" +#include "ariec61850/mms/utc_time.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kExpectedPdu{ + 0x61U, 0x54U, 0x80U, 0x10U, 0x4CU, 0x44U, 0x30U, 0x2FU, 0x4CU, 0x4CU, + 0x4EU, 0x30U, 0x24U, 0x47U, 0x4FU, 0x24U, 0x67U, 0x63U, 0x62U, 0x31U, + 0x81U, 0x02U, 0x03U, 0xE8U, 0x82U, 0x0CU, 0x4CU, 0x44U, 0x30U, 0x2FU, + 0x4CU, 0x4CU, 0x4EU, 0x30U, 0x24U, 0x44U, 0x53U, 0x31U, 0x83U, 0x06U, + 0x47U, 0x4FU, 0x4FU, 0x53U, 0x45U, 0x31U, 0x84U, 0x08U, 0x6AU, 0x2BU, + 0x77U, 0x25U, 0x40U, 0x00U, 0x00U, 0x0AU, 0x85U, 0x01U, 0x03U, 0x86U, + 0x01U, 0x09U, 0x87U, 0x01U, 0x00U, 0x88U, 0x01U, 0x02U, 0x89U, 0x01U, + 0x01U, 0x8AU, 0x01U, 0x03U, 0xABU, 0x0AU, 0x83U, 0x01U, 0x01U, 0x85U, + 0x01U, 0xFDU, 0x8AU, 0x02U, 0x4FU, 0x4BU}; + +constexpr std::array kExpectedFrame{ + 0x01U, 0x0CU, 0xCDU, 0x01U, 0x00U, 0x01U, 0x02U, 0x00U, 0x00U, 0x00U, + 0x00U, 0x01U, 0x81U, 0x00U, 0x80U, 0x64U, 0x88U, 0xB8U, 0x10U, 0x01U, + 0x00U, 0x5EU, 0x00U, 0x00U, 0x00U, 0x00U, 0x61U, 0x54U, 0x80U, 0x10U, + 0x4CU, 0x44U, 0x30U, 0x2FU, 0x4CU, 0x4CU, 0x4EU, 0x30U, 0x24U, 0x47U, + 0x4FU, 0x24U, 0x67U, 0x63U, 0x62U, 0x31U, 0x81U, 0x02U, 0x03U, 0xE8U, + 0x82U, 0x0CU, 0x4CU, 0x44U, 0x30U, 0x2FU, 0x4CU, 0x4CU, 0x4EU, 0x30U, + 0x24U, 0x44U, 0x53U, 0x31U, 0x83U, 0x06U, 0x47U, 0x4FU, 0x4FU, 0x53U, + 0x45U, 0x31U, 0x84U, 0x08U, 0x6AU, 0x2BU, 0x77U, 0x25U, 0x40U, 0x00U, + 0x00U, 0x0AU, 0x85U, 0x01U, 0x03U, 0x86U, 0x01U, 0x09U, 0x87U, 0x01U, + 0x00U, 0x88U, 0x01U, 0x02U, 0x89U, 0x01U, 0x01U, 0x8AU, 0x01U, 0x03U, + 0xABU, 0x0AU, 0x83U, 0x01U, 0x01U, 0x85U, 0x01U, 0xFDU, 0x8AU, 0x02U, + 0x4FU, 0x4BU}; + +[[nodiscard]] goose::GoosePdu make_pdu() { + goose::GoosePdu pdu; + pdu.go_cb_ref = "LD0/LLN0$GO$gcb1"; + pdu.time_allowed_to_live_milliseconds = 1'000U; + pdu.data_set_reference = "LD0/LLN0$DS1"; + pdu.go_id = "GOOSE1"; + pdu.timestamp = mms::Iec61850UtcTime{ + std::chrono::system_clock::time_point{std::chrono::seconds{1'781'233'445}} + + std::chrono::milliseconds{250}, + 0x0AU}; + pdu.state_number = 3U; + pdu.sequence_number = 9U; + pdu.test = false; + pdu.configuration_revision = 2U; + pdu.needs_commissioning = true; + pdu.values = { + mms::MmsDataValue::boolean(true), + mms::MmsDataValue::integer(-3), + mms::MmsDataValue::visible_string("OK")}; + return pdu; +} + +[[nodiscard]] goose::GooseFrame make_frame() { + const std::array destination{ + 0x01U, 0x0CU, 0xCDU, 0x01U, 0x00U, 0x01U}; + const std::array source{ + 0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x01U}; + + goose::GooseFrame frame; + frame.destination = ethernet::MacAddress{destination}; + frame.source = ethernet::MacAddress{source}; + frame.vlan = ethernet::VlanTag{4U, 100U}; + frame.app_id = 0x1001U; + frame.pdu = make_pdu(); + return frame; +} + +[[nodiscard]] bool equals( + const std::span left, + const std::span right) noexcept { + if (left.size() != right.size()) { + return false; + } + for (std::size_t index = 0U; index < left.size(); ++index) { + if (left[index] != right[index]) { + return false; + } + } + return true; +} + +} // namespace + +int main() { + auto pdu = make_pdu(); + const auto pdu_size = goose::GoosePduCodec::encoded_size(pdu); + if (!pdu_size || *pdu_size != kExpectedPdu.size()) { + return 1; + } + + std::array pdu_buffer{}; + const auto pdu_result = goose::GoosePduCodec::encode_into(pdu, pdu_buffer); + if (!pdu_result.success() || pdu_result.bytes_written != kExpectedPdu.size() || + !equals( + std::span{pdu_buffer}.first(pdu_result.bytes_written), + kExpectedPdu)) { + return 2; + } + + std::array small{}; + const auto small_result = goose::GoosePduCodec::encode_into(pdu, small); + if (small_result.status != wire::EncodeStatus::buffer_too_small || + small_result.required_bytes != kExpectedPdu.size()) { + return 3; + } + + auto frame = make_frame(); + const auto frame_size = goose::GooseFrameCodec::encoded_size(frame); + if (!frame_size || *frame_size != kExpectedFrame.size()) { + return 4; + } + + std::array frame_buffer{}; + const auto frame_result = goose::GooseFrameCodec::encode_into(frame, frame_buffer); + if (!frame_result.success() || frame_result.bytes_written != kExpectedFrame.size() || + !equals( + std::span{frame_buffer}.first(frame_result.bytes_written), + kExpectedFrame)) { + return 5; + } + + const auto values_capacity = frame.pdu.values.capacity(); + const auto go_cb_capacity = frame.pdu.go_cb_ref.capacity(); + const auto dataset_capacity = frame.pdu.data_set_reference.capacity(); + const auto go_id_capacity = frame.pdu.go_id.capacity(); + + // Simulate a sustained retransmission workload. Only scalar state/sequence + // fields change; the bounded encoder must not grow configured storage. + constexpr std::uint32_t retransmissions = 20'000U; + for (std::uint32_t index = 0U; index < retransmissions; ++index) { + frame.pdu.sequence_number = index; + const auto result = goose::GooseFrameCodec::encode_into(frame, frame_buffer); + if (!result.success() || result.bytes_written == 0U) { + return 6; + } + } + + if (frame.pdu.values.capacity() != values_capacity || + frame.pdu.go_cb_ref.capacity() != go_cb_capacity || + frame.pdu.data_set_reference.capacity() != dataset_capacity || + frame.pdu.go_id.capacity() != go_id_capacity) { + return 7; + } + + return 0; +} diff --git a/embedded/hard_profile/CMakeLists.txt b/embedded/hard_profile/CMakeLists.txt new file mode 100644 index 000000000..6dfc824c2 --- /dev/null +++ b/embedded/hard_profile/CMakeLists.txt @@ -0,0 +1,237 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARIEC61850HardEmbeddedProfile + VERSION 0.1.0 + DESCRIPTION "No-exception/no-RTTI MCU profiles for bounded process-bus and MMS server paths" + LANGUAGES CXX) + +set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") + +if(MSVC) + message(FATAL_ERROR "Hard embedded profiles are validated with GCC/Clang -fno-exceptions.") +endif() + +function(ariec61850_configure_hard_target target) + target_compile_features(${target} PRIVATE cxx_std_20) + target_include_directories(${target} PRIVATE ${ARIEC61850_ROOT}/include) + target_compile_definitions(${target} PRIVATE + ARIEC61850_EMBEDDED_PROFILE=1 + ARIEC61850_HOST_SERVICES=0 + ARIEC61850_NO_EXCEPTIONS=1) + target_compile_options(${target} PRIVATE + -Wall + -Wextra + -Wpedantic + -Wconversion + -Wsign-conversion + -Werror + -fno-exceptions + -fno-rtti) +endfunction() + +add_executable(ariec61850_hard_sv_smoke + ${CMAKE_CURRENT_LIST_DIR}/../sv_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/ethernet/ethernet_encode.cpp + ${ARIEC61850_ROOT}/src/mms/utc_time.cpp + ${ARIEC61850_ROOT}/src/sampled_values/pdu_codec.cpp + ${ARIEC61850_ROOT}/src/sampled_values/frame_codec.cpp + ${ARIEC61850_ROOT}/src/sampled_values/sample_counter.cpp) +ariec61850_configure_hard_target(ariec61850_hard_sv_smoke) + +add_executable(ariec61850_hard_goose_smoke + ${CMAKE_CURRENT_LIST_DIR}/../goose_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/ethernet/ethernet_encode.cpp + ${ARIEC61850_ROOT}/src/mms/utc_time.cpp + ${ARIEC61850_ROOT}/src/mms/data_value.cpp + ${ARIEC61850_ROOT}/src/goose/pdu_encode.cpp + ${ARIEC61850_ROOT}/src/goose/frame_encode.cpp) +ariec61850_configure_hard_target(ariec61850_hard_goose_smoke) + +add_executable(ariec61850_hard_osi_transport_smoke + ${CMAKE_CURRENT_LIST_DIR}/../osi_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/osi/tpkt_span.cpp + ${ARIEC61850_ROOT}/src/osi/cotp_span.cpp) +ariec61850_configure_hard_target(ariec61850_hard_osi_transport_smoke) + +add_executable(ariec61850_hard_session_presentation_smoke + ${CMAKE_CURRENT_LIST_DIR}/../session_presentation_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp + ${ARIEC61850_ROOT}/src/osi/session_span.cpp + ${ARIEC61850_ROOT}/src/osi/presentation_span.cpp) +ariec61850_configure_hard_target(ariec61850_hard_session_presentation_smoke) + +add_executable(ariec61850_hard_association_smoke + ${CMAKE_CURRENT_LIST_DIR}/../association_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp + ${ARIEC61850_ROOT}/src/osi/session_span.cpp + ${ARIEC61850_ROOT}/src/osi/presentation_span.cpp + ${ARIEC61850_ROOT}/src/acse/association_span.cpp) +ariec61850_configure_hard_target(ariec61850_hard_association_smoke) + +add_executable(ariec61850_hard_mms_pdu_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_pdu_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp + ${ARIEC61850_ROOT}/src/osi/session_span.cpp + ${ARIEC61850_ROOT}/src/osi/presentation_span.cpp + ${ARIEC61850_ROOT}/src/mms/pdu_span.cpp) +ariec61850_configure_hard_target(ariec61850_hard_mms_pdu_smoke) + +add_executable(ariec61850_hard_mms_get_name_list_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_get_name_list_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp + ${ARIEC61850_ROOT}/src/osi/session_span.cpp + ${ARIEC61850_ROOT}/src/osi/presentation_span.cpp + ${ARIEC61850_ROOT}/src/mms/pdu_span.cpp + ${ARIEC61850_ROOT}/src/mms/services_span.cpp) +ariec61850_configure_hard_target(ariec61850_hard_mms_get_name_list_smoke) + +add_executable(ariec61850_hard_mms_static_model_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_static_model_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp + ${ARIEC61850_ROOT}/src/mms/pdu_span.cpp + ${ARIEC61850_ROOT}/src/mms/services_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_object_table.cpp) +ariec61850_configure_hard_target(ariec61850_hard_mms_static_model_smoke) + +add_executable(ariec61850_hard_mms_write_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_write_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp + ${ARIEC61850_ROOT}/src/mms/pdu_span.cpp + ${ARIEC61850_ROOT}/src/mms/services_span.cpp + ${ARIEC61850_ROOT}/src/mms/write_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_object_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_write.cpp) +ariec61850_configure_hard_target(ariec61850_hard_mms_write_smoke) + +add_executable(ariec61850_hard_mms_dispatcher_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_dispatcher_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp + ${ARIEC61850_ROOT}/src/mms/pdu_span.cpp + ${ARIEC61850_ROOT}/src/mms/services_span.cpp + ${ARIEC61850_ROOT}/src/mms/write_span.cpp + ${ARIEC61850_ROOT}/src/mms/data_set_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_object_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_write.cpp + ${ARIEC61850_ROOT}/src/mms/static_data_set_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_dispatcher.cpp) +ariec61850_configure_hard_target(ariec61850_hard_mms_dispatcher_smoke) + +add_executable(ariec61850_hard_mms_connection_runtime_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_connection_runtime_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp + ${ARIEC61850_ROOT}/src/osi/tpkt_span.cpp + ${ARIEC61850_ROOT}/src/osi/cotp_span.cpp + ${ARIEC61850_ROOT}/src/osi/session_span.cpp + ${ARIEC61850_ROOT}/src/osi/presentation_span.cpp + ${ARIEC61850_ROOT}/src/acse/association_span.cpp + ${ARIEC61850_ROOT}/src/mms/pdu_span.cpp + ${ARIEC61850_ROOT}/src/mms/services_span.cpp + ${ARIEC61850_ROOT}/src/mms/write_span.cpp + ${ARIEC61850_ROOT}/src/mms/data_set_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_object_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_write.cpp + ${ARIEC61850_ROOT}/src/mms/static_data_set_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_dispatcher.cpp + ${ARIEC61850_ROOT}/src/mms/connection_runtime.cpp) +ariec61850_configure_hard_target(ariec61850_hard_mms_connection_runtime_smoke) + +add_executable(ariec61850_hard_mms_data_set_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_data_set_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp + ${ARIEC61850_ROOT}/src/mms/pdu_span.cpp + ${ARIEC61850_ROOT}/src/mms/services_span.cpp + ${ARIEC61850_ROOT}/src/mms/write_span.cpp + ${ARIEC61850_ROOT}/src/mms/data_set_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_object_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_write.cpp + ${ARIEC61850_ROOT}/src/mms/static_data_set_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_dispatcher.cpp) +ariec61850_configure_hard_target(ariec61850_hard_mms_data_set_smoke) + +add_executable(ariec61850_hard_mms_information_report_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_information_report_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp + ${ARIEC61850_ROOT}/src/mms/pdu_span.cpp + ${ARIEC61850_ROOT}/src/mms/services_span.cpp + ${ARIEC61850_ROOT}/src/mms/data_set_span.cpp + ${ARIEC61850_ROOT}/src/mms/information_report_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_object_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_data_set_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_information_report.cpp) +ariec61850_configure_hard_target(ariec61850_hard_mms_information_report_smoke) + +add_executable(ariec61850_hard_mms_urcb_runtime_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_urcb_runtime_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp + ${ARIEC61850_ROOT}/src/mms/pdu_span.cpp + ${ARIEC61850_ROOT}/src/mms/services_span.cpp + ${ARIEC61850_ROOT}/src/mms/data_set_span.cpp + ${ARIEC61850_ROOT}/src/mms/information_report_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_object_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_data_set_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_information_report.cpp + ${ARIEC61850_ROOT}/src/mms/static_urcb_runtime.cpp) +ariec61850_configure_hard_target(ariec61850_hard_mms_urcb_runtime_smoke) + +add_executable(ariec61850_hard_mms_urcb_objects_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_urcb_objects_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp + ${ARIEC61850_ROOT}/src/mms/pdu_span.cpp + ${ARIEC61850_ROOT}/src/mms/services_span.cpp + ${ARIEC61850_ROOT}/src/mms/write_span.cpp + ${ARIEC61850_ROOT}/src/mms/data_set_span.cpp + ${ARIEC61850_ROOT}/src/mms/information_report_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_object_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_write.cpp + ${ARIEC61850_ROOT}/src/mms/static_data_set_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_dispatcher.cpp + ${ARIEC61850_ROOT}/src/mms/static_information_report.cpp + ${ARIEC61850_ROOT}/src/mms/static_urcb_runtime.cpp + ${ARIEC61850_ROOT}/src/mms/static_urcb_objects.cpp) +ariec61850_configure_hard_target(ariec61850_hard_mms_urcb_objects_smoke) + +add_executable(ariec61850_hard_mms_report_connection_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_report_connection_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp + ${ARIEC61850_ROOT}/src/osi/tpkt_span.cpp + ${ARIEC61850_ROOT}/src/osi/cotp_span.cpp + ${ARIEC61850_ROOT}/src/osi/session_span.cpp + ${ARIEC61850_ROOT}/src/osi/presentation_span.cpp + ${ARIEC61850_ROOT}/src/acse/association_span.cpp + ${ARIEC61850_ROOT}/src/mms/pdu_span.cpp + ${ARIEC61850_ROOT}/src/mms/services_span.cpp + ${ARIEC61850_ROOT}/src/mms/write_span.cpp + ${ARIEC61850_ROOT}/src/mms/data_set_span.cpp + ${ARIEC61850_ROOT}/src/mms/information_report_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_object_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_write.cpp + ${ARIEC61850_ROOT}/src/mms/static_data_set_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_dispatcher.cpp + ${ARIEC61850_ROOT}/src/mms/static_information_report.cpp + ${ARIEC61850_ROOT}/src/mms/static_urcb_runtime.cpp + ${ARIEC61850_ROOT}/src/mms/connection_runtime.cpp + ${ARIEC61850_ROOT}/src/mms/static_report_connection.cpp) +ariec61850_configure_hard_target(ariec61850_hard_mms_report_connection_smoke) + +enable_testing() +add_test(NAME ariec61850_hard_sv_smoke COMMAND ariec61850_hard_sv_smoke) +add_test(NAME ariec61850_hard_goose_smoke COMMAND ariec61850_hard_goose_smoke) +add_test(NAME ariec61850_hard_osi_transport_smoke COMMAND ariec61850_hard_osi_transport_smoke) +add_test(NAME ariec61850_hard_session_presentation_smoke COMMAND ariec61850_hard_session_presentation_smoke) +add_test(NAME ariec61850_hard_association_smoke COMMAND ariec61850_hard_association_smoke) +add_test(NAME ariec61850_hard_mms_pdu_smoke COMMAND ariec61850_hard_mms_pdu_smoke) +add_test(NAME ariec61850_hard_mms_get_name_list_smoke COMMAND ariec61850_hard_mms_get_name_list_smoke) +add_test(NAME ariec61850_hard_mms_static_model_smoke COMMAND ariec61850_hard_mms_static_model_smoke) +add_test(NAME ariec61850_hard_mms_write_smoke COMMAND ariec61850_hard_mms_write_smoke) +add_test(NAME ariec61850_hard_mms_dispatcher_smoke COMMAND ariec61850_hard_mms_dispatcher_smoke) +add_test(NAME ariec61850_hard_mms_connection_runtime_smoke COMMAND ariec61850_hard_mms_connection_runtime_smoke) +add_test(NAME ariec61850_hard_mms_data_set_smoke COMMAND ariec61850_hard_mms_data_set_smoke) +add_test(NAME ariec61850_hard_mms_information_report_smoke COMMAND ariec61850_hard_mms_information_report_smoke) +add_test(NAME ariec61850_hard_mms_urcb_runtime_smoke COMMAND ariec61850_hard_mms_urcb_runtime_smoke) +add_test(NAME ariec61850_hard_mms_urcb_objects_smoke COMMAND ariec61850_hard_mms_urcb_objects_smoke) +add_test(NAME ariec61850_hard_mms_report_connection_smoke COMMAND ariec61850_hard_mms_report_connection_smoke) + +message(STATUS "arstack61850 hard embedded profile: exceptions disabled=ON") +message(STATUS "arstack61850 hard embedded profile: RTTI disabled=ON") +message(STATUS "arstack61850 hard embedded profile: host services excluded") +message(STATUS "arstack61850 hard embedded profile: bounded SV + GOOSE + OSI + ACSE + MMS static dispatcher/connection runtime/DataSet/InformationReport/URCB/RCB-object/report-transport gates=ON") diff --git a/embedded/mms_brcb_connection_hard_profile_smoke.cpp b/embedded/mms_brcb_connection_hard_profile_smoke.cpp new file mode 100644 index 000000000..b3ce88e09 --- /dev/null +++ b/embedded/mms_brcb_connection_hard_profile_smoke.cpp @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/mms/static_brcb_connection.hpp" +#include "ariec61850/mms/static_dispatcher.hpp" +#include "ariec61850/osi/cotp_span.hpp" +#include "ariec61850/osi/presentation_span.hpp" +#include "ariec61850/osi/session_span.hpp" +#include "ariec61850/osi/tpkt_span.hpp" + +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kReportTime{ + 0x00U, 0x00U, 0x12U, 0x34U, 0x00U, 0x01U}; + +constexpr std::array kAssociationRequest{ + 0x0DU,0xB6U,0x05U,0x06U,0x13U,0x01U,0x00U,0x16U,0x01U,0x02U,0x14U,0x02U, + 0x00U,0x02U,0x33U,0x02U,0x00U,0x01U,0x34U,0x02U,0x00U,0x01U,0xC1U,0xA0U, + 0x31U,0x81U,0x9DU,0xA0U,0x03U,0x80U,0x01U,0x01U,0xA2U,0x81U,0x95U,0x81U, + 0x04U,0x00U,0x00U,0x00U,0x01U,0x82U,0x04U,0x00U,0x00U,0x00U,0x01U,0xA4U, + 0x23U,0x30U,0x0FU,0x02U,0x01U,0x01U,0x06U,0x04U,0x52U,0x01U,0x00U,0x01U, + 0x30U,0x04U,0x06U,0x02U,0x51U,0x01U,0x30U,0x10U,0x02U,0x01U,0x03U,0x06U, + 0x05U,0x28U,0xCAU,0x22U,0x02U,0x01U,0x30U,0x04U,0x06U,0x02U,0x51U,0x01U, + 0x61U,0x62U,0x30U,0x60U,0x02U,0x01U,0x01U,0xA0U,0x5BU,0x60U,0x59U,0xA1U, + 0x07U,0x06U,0x05U,0x28U,0xCAU,0x22U,0x02U,0x03U,0xA2U,0x07U,0x06U,0x05U, + 0x29U,0x01U,0x87U,0x67U,0x01U,0xA3U,0x03U,0x02U,0x01U,0x0CU,0xA6U,0x06U, + 0x06U,0x04U,0x29U,0x01U,0x87U,0x67U,0xA7U,0x03U,0x02U,0x01U,0x0CU,0xBEU, + 0x33U,0x28U,0x31U,0x06U,0x02U,0x51U,0x01U,0x02U,0x01U,0x03U,0xA0U,0x28U, + 0xA8U,0x26U,0x80U,0x03U,0x00U,0xFDU,0xE8U,0x81U,0x01U,0x0AU,0x82U,0x01U, + 0x0AU,0x83U,0x01U,0x05U,0xA4U,0x16U,0x80U,0x01U,0x01U,0x81U,0x03U,0x05U, + 0xF1U,0x00U,0x82U,0x0CU,0x03U,0xEEU,0x1CU,0x00U,0x00U,0x04U,0x08U,0x00U, + 0x00U,0x79U,0xEFU,0x18U}; + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) return {wire::EncodeStatus::value_out_of_range, 0U, required}; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = *static_cast(context) ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] bool establish( + mms::MmsStaticConnectionRuntime& connection, + std::span request, + std::span response, + std::span workspace, + std::span scratch) noexcept { + constexpr std::array tpdu_size{0x0AU}; + constexpr std::array source_tsap{0x00U, 0x01U}; + constexpr std::array destination_tsap{0x00U, 0x01U}; + const std::array parameters{ + osi::CotpParameterView{osi::CotpSpanCodec::tpdu_size_parameter, tpdu_size}, + osi::CotpParameterView{osi::CotpSpanCodec::source_tsap_parameter, source_tsap}, + osi::CotpParameterView{osi::CotpSpanCodec::destination_tsap_parameter, destination_tsap}}; + + const auto cr = osi::CotpSpanCodec::encode_connection_request_into( + 0x0001U, parameters, scratch); + if (!cr.success()) return false; + const auto cr_tpkt = osi::TpktSpanCodec::encode_into( + scratch.first(cr.bytes_written), request); + if (!cr_tpkt.success()) return false; + auto result = connection.process_tcp_window( + request.first(cr_tpkt.bytes_written), response, workspace); + if (!result.response_ready() || + connection.state() != mms::MmsStaticConnectionState::awaiting_association) { + return false; + } + + const auto cotp = osi::CotpSpanCodec::encode_data_into(kAssociationRequest, scratch); + if (!cotp.success()) return false; + const auto association = osi::TpktSpanCodec::encode_into( + scratch.first(cotp.bytes_written), request); + if (!association.success()) return false; + result = connection.process_tcp_window( + request.first(association.bytes_written), response, workspace); + return result.response_ready() && + connection.state() == mms::MmsStaticConnectionState::established && + connection.mms_presentation_context_id() == 3U; +} + +[[nodiscard]] bool decode_report_frame( + const std::span frame, + mms::MmsInformationReportView& report) noexcept { + osi::TpktFrameView tpkt; + osi::CotpTpduView cotp; + osi::SessionDataTransferView session; + osi::PresentationPdvView pdv; + return osi::TpktSpanCodec::try_decode_view(frame, tpkt) && + osi::CotpSpanCodec::try_decode_view(tpkt.payload, cotp) && + cotp.kind == osi::CotpWireKind::data && cotp.end_of_transmission && + osi::SessionSpanCodec::try_decode_data_transfer_view(cotp.user_data, session) && + osi::PresentationSpanCodec::try_decode_fully_encoded_data_view( + session.presentation_payload, pdv) && + pdv.context_id == 3U && + mms::MmsInformationReportSpanCodec::try_decode_information_report( + pdv.single_asn1_type, report); +} + +[[nodiscard]] bool decode_boolean( + const mms::MmsReadAccessResultView& item, + bool& value) noexcept { + value = false; + if (!item.success) return false; + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(item.encoded_data, tlv) || + tlv.tag_class != asn1::BerClass::context_specific || + tlv.tag_number != 3 || tlv.constructed || tlv.value.size() != 1U) return false; + value = tlv.value[0] != 0U; + return true; +} + +} // namespace + +int main() { + bool value = true; + const std::array objects{ + mms::MmsStaticObjectEntry{"LD0", "X1", kBooleanType, read_boolean, &value, false}}; + const mms::MmsStaticObjectTable object_table{objects}; + const std::array members{ + mms::MmsStaticDataSetMember{"LD0", "X1"}}; + const std::array data_sets{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", members, false}}; + const mms::MmsStaticDataSetTable data_set_table{data_sets}; + const mms::MmsStaticApplicationDispatcher dispatcher{object_table, data_set_table}; + mms::MmsStaticConnectionRuntime connection{dispatcher}; + + const mms::MmsStaticBrcbDefinition definition{ + "LD0", "LLN0$BR$Events", "LD0/LLN0$BR$Events", + "LD0", "LLN0$Events", 4U, {0x7FU, 0x80U}, 0U, 0x70U}; + std::array slot_storage{}; + std::array slots{ + mms::MmsStaticBrcbSlot{slot_storage}}; + mms::MmsStaticBrcbPendingState pending{}; + mms::MmsStaticBrcbRuntime reports{ + definition, pending, slots, object_table, data_set_table}; + if (!reports.initialize() || reports.set_enabled(true) != mms::MmsStaticBrcbStatus::ok || + reports.notify(0U, mms::MmsStaticBrcbEventReason::data_change, 100U) != + mms::MmsStaticBrcbStatus::ok) { + return 1; + } + + mms::MmsStaticBrcbCapturePlan plan; + std::array staging{}; + std::array value_workspace{}; + if (!reports.next_due(100U, plan) || + !reports.capture(plan, kReportTime, staging, value_workspace).success() || + reports.queue_size() != 1U) { + return 2; + } + + // Mutate the live backend after capture. Delivery must retain the old true snapshot. + value = false; + std::array request{}; + std::array response{}; + std::array workspace{}; + std::array scratch{}; + auto delivery = mms::MmsStaticBrcbConnection::poll( + connection, reports, response, workspace); + if (delivery.status != mms::MmsStaticBrcbConnectionStatus::not_established || + reports.queue_size() != 1U) { + return 3; + } + if (!establish(connection, request, response, workspace, scratch)) return 4; + + mms::MmsStaticBrcbEntryView held_entry; + if (!reports.front(held_entry) || held_entry.entry_id.size() != 8U || + held_entry.entry_id[7U] != 1U || + reports.set_enabled(false) != mms::MmsStaticBrcbStatus::ok) { + return 5; + } + delivery = mms::MmsStaticBrcbConnection::poll( + connection, reports, response, workspace); + if (delivery.status != mms::MmsStaticBrcbConnectionStatus::reporting_disabled || + reports.queue_size() != 1U || !reports.front(held_entry) || + held_entry.entry_id[7U] != 1U || + reports.set_enabled(true) != mms::MmsStaticBrcbStatus::ok) { + return 6; + } + + std::array tiny_response{}; + delivery = mms::MmsStaticBrcbConnection::poll( + connection, reports, tiny_response, workspace); + if (delivery.status != mms::MmsStaticBrcbConnectionStatus::response_buffer_too_small || + delivery.required_response_bytes <= tiny_response.size() || + delivery.entry_id[7] != 1U || reports.queue_size() != 1U) { + return 7; + } + + std::array tiny_workspace{}; + delivery = mms::MmsStaticBrcbConnection::poll( + connection, reports, response, tiny_workspace); + if (delivery.status != mms::MmsStaticBrcbConnectionStatus::workspace_too_small || + delivery.required_workspace_bytes <= tiny_workspace.size() || + delivery.entry_id[7] != 1U || reports.queue_size() != 1U) { + return 8; + } + + delivery = mms::MmsStaticBrcbConnection::poll( + connection, reports, response, workspace); + if (!delivery.response_ready() || delivery.bytes_written == 0U || + delivery.entry_id[7] != 1U || delivery.sequence_number != 1U || + delivery.buffer_overflow || reports.queue_size() != 0U) { + return 9; + } + + mms::MmsInformationReportView decoded; + if (!decode_report_frame( + std::span{response}.first(delivery.bytes_written), decoded) || + decoded.item_count != 12U) { + return 10; + } + mms::MmsReadAccessResultView item; + bool historical_value = false; + if (!decoded.try_item(10U, item) || !decode_boolean(item, historical_value) || + !historical_value) { + return 11; + } + + // Queue another historical entry, then drop the association. The queue must survive reset. + if (reports.notify(0U, mms::MmsStaticBrcbEventReason::data_update, 200U) != + mms::MmsStaticBrcbStatus::ok || + !reports.next_due(200U, plan) || + !reports.capture(plan, kReportTime, staging, value_workspace).success() || + reports.queue_size() != 1U) { + return 12; + } + connection.reset(); + delivery = mms::MmsStaticBrcbConnection::poll( + connection, reports, response, workspace); + if (delivery.status != mms::MmsStaticBrcbConnectionStatus::not_established || + reports.queue_size() != 1U) { + return 13; + } + if (!establish(connection, request, response, workspace, scratch)) return 14; + delivery = mms::MmsStaticBrcbConnection::poll( + connection, reports, response, workspace); + if (!delivery.response_ready() || delivery.entry_id[7] != 2U || + reports.queue_size() != 0U || + mms::MmsStaticBrcbConnection::poll( + connection, reports, response, workspace).status != + mms::MmsStaticBrcbConnectionStatus::no_report_available) { + return 15; + } + + return 0; +} diff --git a/embedded/mms_brcb_hard_profile_smoke.cpp b/embedded/mms_brcb_hard_profile_smoke.cpp new file mode 100644 index 000000000..0dd71f886 --- /dev/null +++ b/embedded/mms_brcb_hard_profile_smoke.cpp @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/mms/information_report_span.hpp" +#include "ariec61850/mms/static_brcb_runtime.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kReportTime{ + 0x00U, 0x00U, 0x12U, 0x34U, 0x00U, 0x01U}; + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = *static_cast(context) ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] bool decode_boolean( + const mms::MmsReadAccessResultView& item, + bool& value) noexcept { + value = false; + if (!item.success) return false; + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(item.encoded_data, tlv) || + tlv.tag_class != asn1::BerClass::context_specific || + tlv.tag_number != 3 || tlv.constructed || tlv.value.size() != 1U) { + return false; + } + value = tlv.value[0] != 0U; + return true; +} + +[[nodiscard]] bool decode_bit_string( + const mms::MmsReadAccessResultView& item, + const std::uint8_t unused, + const std::uint8_t value) noexcept { + if (!item.success) return false; + asn1::BerTlvView tlv; + return asn1::BerSpanReader::try_read_exact(item.encoded_data, tlv) && + tlv.tag_class == asn1::BerClass::context_specific && + tlv.tag_number == 4 && !tlv.constructed && tlv.value.size() == 2U && + tlv.value[0] == unused && tlv.value[1] == value; +} + +[[nodiscard]] bool decode_octet_string( + const mms::MmsReadAccessResultView& item, + const std::span expected) noexcept { + if (!item.success) return false; + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(item.encoded_data, tlv) || + tlv.tag_class != asn1::BerClass::context_specific || + tlv.tag_number != 9 || tlv.constructed || tlv.value.size() != expected.size()) { + return false; + } + for (std::size_t index = 0U; index < expected.size(); ++index) { + if (tlv.value[index] != expected[index]) return false; + } + return true; +} + +[[nodiscard]] bool expected_entry( + const std::array& entry, + const std::uint64_t value) noexcept { + for (std::size_t index = 0U; index < entry.size(); ++index) { + const auto shift = static_cast((entry.size() - 1U - index) * 8U); + if (entry[index] != static_cast((value >> shift) & 0xFFU)) { + return false; + } + } + return true; +} + +} // namespace + +int main() { + bool first = true; + bool second = false; + bool third = true; + const std::array objects{ + mms::MmsStaticObjectEntry{"LD0", "X1", kBooleanType, read_boolean, &first, false}, + mms::MmsStaticObjectEntry{"LD0", "X2", kBooleanType, read_boolean, &second, false}, + mms::MmsStaticObjectEntry{"LD0", "X3", kBooleanType, read_boolean, &third, false}}; + const mms::MmsStaticObjectTable object_table{objects}; + + const std::array members{ + mms::MmsStaticDataSetMember{"LD0", "X1"}, + mms::MmsStaticDataSetMember{"LD0", "X2"}, + mms::MmsStaticDataSetMember{"LD0", "X3"}}; + const std::array data_sets{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", members, false}}; + const mms::MmsStaticDataSetTable data_set_table{data_sets}; + + const mms::MmsStaticBrcbDefinition definition{ + "LD0", + "LLN0$BR$Events", + "LD0/LLN0$BR$Events", + "LD0", + "LLN0$Events", + 11U, + {0x7FU, 0x80U}, + 20U, + 0x70U}; + + std::array slot0{}; + std::array slot1{}; + std::array slots{ + mms::MmsStaticBrcbSlot{slot0}, + mms::MmsStaticBrcbSlot{slot1}}; + mms::MmsStaticBrcbPendingState pending{}; + mms::MmsStaticBrcbRuntime runtime{ + definition, pending, slots, object_table, data_set_table}; + if (!runtime.initialize() || !runtime.valid() || runtime.enabled() || + runtime.queue_size() != 0U || runtime.queue_capacity() != 2U) { + return 1; + } + if (runtime.notify(0U, mms::MmsStaticBrcbEventReason::data_change, 0U) != + mms::MmsStaticBrcbStatus::temporarily_unavailable || + runtime.set_enabled(true) != mms::MmsStaticBrcbStatus::ok) { + return 2; + } + + if (runtime.notify(0U, mms::MmsStaticBrcbEventReason::data_change, 100U) != + mms::MmsStaticBrcbStatus::ok || + runtime.notify(2U, mms::MmsStaticBrcbEventReason::quality_change, 105U) != + mms::MmsStaticBrcbStatus::ok) { + return 3; + } + mms::MmsStaticBrcbCapturePlan plan; + if (runtime.next_due(119U, plan) || !runtime.next_due(120U, plan) || + plan.entry_number != 1U || plan.sequence_number != 1U || plan.buffer_overflow) { + return 4; + } + + std::array tiny{}; + std::array staging{}; + std::array workspace{}; + auto capture = runtime.capture(plan, kReportTime, tiny, workspace); + if (capture.status != mms::MmsStaticBrcbStatus::response_buffer_too_small || + capture.required_bytes <= tiny.size() || runtime.queue_size() != 0U || + !runtime.next_due(120U, plan) || plan.entry_number != 1U) { + return 5; + } + + capture = runtime.capture(plan, kReportTime, staging, workspace); + if (!capture.success() || runtime.queue_size() != 1U || + capture.included_member_count != 2U || !expected_entry(capture.entry_id, 1U)) { + return 6; + } + + mms::MmsStaticBrcbEntryView entry; + mms::MmsInformationReportView report; + if (!runtime.front(entry) || entry.sequence_number != 1U || entry.buffer_overflow || + !mms::MmsInformationReportSpanCodec::try_decode_information_report( + entry.mms_pdu, report) || report.item_count != 15U) { + return 7; + } + mms::MmsReadAccessResultView item; + bool boolean_value = false; + if (!report.try_item(5U, item) || !decode_boolean(item, boolean_value) || boolean_value || + !report.try_item(6U, item) || !decode_octet_string(item, capture.entry_id) || + !report.try_item(8U, item) || !decode_bit_string(item, 5U, 0xA0U) || + !report.try_item(11U, item) || !decode_boolean(item, boolean_value) || !boolean_value || + !report.try_item(12U, item) || !decode_boolean(item, boolean_value) || !boolean_value || + !report.try_item(13U, item) || !decode_bit_string(item, 2U, 0x80U) || + !report.try_item(14U, item) || !decode_bit_string(item, 2U, 0x40U)) { + return 8; + } + + if (runtime.notify(1U, mms::MmsStaticBrcbEventReason::data_update, 200U) != + mms::MmsStaticBrcbStatus::ok || + !runtime.next_due(220U, plan)) { + return 9; + } + capture = runtime.capture(plan, kReportTime, staging, workspace); + if (!capture.success() || runtime.queue_size() != 2U || + !expected_entry(capture.entry_id, 2U)) { + return 10; + } + + if (runtime.notify(0U, mms::MmsStaticBrcbEventReason::data_change, 300U) != + mms::MmsStaticBrcbStatus::ok || + !runtime.next_due(320U, plan) || !plan.buffer_overflow) { + return 11; + } + const auto stale_plan = plan; + const auto first_entry_id = std::array{ + 0U, 0U, 0U, 0U, 0U, 0U, 0U, 1U}; + if (runtime.commit_delivery(first_entry_id) != mms::MmsStaticBrcbStatus::ok || + runtime.capture(stale_plan, kReportTime, staging, workspace).status != + mms::MmsStaticBrcbStatus::stale_plan || + !runtime.next_due(320U, plan) || plan.buffer_overflow) { + return 12; + } + capture = runtime.capture(plan, kReportTime, staging, workspace); + if (!capture.success() || runtime.queue_size() != 2U || + !expected_entry(capture.entry_id, 3U) || runtime.dropped_reports() != 0U) { + return 13; + } + + if (runtime.notify(2U, mms::MmsStaticBrcbEventReason::quality_change, 400U) != + mms::MmsStaticBrcbStatus::ok || + !runtime.next_due(420U, plan) || !plan.buffer_overflow) { + return 14; + } + capture = runtime.capture(plan, kReportTime, staging, workspace); + if (!capture.success() || runtime.queue_size() != 2U || + !expected_entry(capture.entry_id, 4U) || runtime.dropped_reports() != 1U) { + return 15; + } + + // Entry 2 was the oldest and must have been dropped. Entry 3 is now front. + if (!runtime.front(entry) || entry.entry_id.size() != 8U || entry.entry_id[7] != 3U || + runtime.commit_delivery(entry.entry_id) != mms::MmsStaticBrcbStatus::ok || + !runtime.front(entry) || entry.entry_id[7] != 4U || !entry.buffer_overflow || + !mms::MmsInformationReportSpanCodec::try_decode_information_report( + entry.mms_pdu, report) || + !report.try_item(5U, item) || !decode_boolean(item, boolean_value) || !boolean_value || + !report.try_item(6U, item)) { + return 16; + } + const std::array fourth_id{0U,0U,0U,0U,0U,0U,0U,4U}; + if (!decode_octet_string(item, fourth_id) || + runtime.commit_delivery(fourth_id) != mms::MmsStaticBrcbStatus::ok || + runtime.queue_size() != 0U) { + return 17; + } + + // Repeated capture/delivery proves bounded steady-state behavior and EntryID progression. + for (std::uint32_t iteration = 0U; iteration < 10'000U; ++iteration) { + const auto now = static_cast(1'000U + (iteration * 25U)); + if (runtime.notify(0U, mms::MmsStaticBrcbEventReason::data_update, now) != + mms::MmsStaticBrcbStatus::ok || + !runtime.next_due(now + 20U, plan)) { + return 18; + } + capture = runtime.capture(plan, kReportTime, staging, workspace); + if (!capture.success() || !runtime.front(entry) || + runtime.commit_delivery(entry.entry_id) != mms::MmsStaticBrcbStatus::ok || + runtime.queue_size() != 0U) { + return 19; + } + } + + if (runtime.set_enabled(false) != mms::MmsStaticBrcbStatus::ok || runtime.enabled() || + runtime.notify(0U, mms::MmsStaticBrcbEventReason::data_change, 999'999U) != + mms::MmsStaticBrcbStatus::temporarily_unavailable) { + return 20; + } + + return 0; +} diff --git a/embedded/mms_brcb_persistence_hard_profile_smoke.cpp b/embedded/mms_brcb_persistence_hard_profile_smoke.cpp new file mode 100644 index 000000000..98b0cd3e4 --- /dev/null +++ b/embedded/mms_brcb_persistence_hard_profile_smoke.cpp @@ -0,0 +1,373 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_brcb_checkpoint_store.hpp" +#include "ariec61850/mms/static_brcb_runtime.hpp" +#include "ariec61850/mms/static_brcb_state_codec.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kReportTime{ + 0x00U, 0x00U, 0x12U, 0x34U, 0x00U, 0x01U}; +constexpr std::size_t kBankBytes = 4096U; +constexpr std::size_t kStorageBytes = kBankBytes * 2U; + +bool g_value = true; + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = *static_cast(context) ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +const std::array kObjects{ + mms::MmsStaticObjectEntry{"LD0", "X1", kBooleanType, read_boolean, &g_value, false}}; +const mms::MmsStaticObjectTable kObjectTable{kObjects}; +const std::array kMembers{ + mms::MmsStaticDataSetMember{"LD0", "X1"}}; +const std::array kDataSets{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", kMembers, false}}; +const mms::MmsStaticDataSetTable kDataSetTable{kDataSets}; + +const mms::MmsStaticBrcbDefinition kDefinition{ + "LD0", + "LLN0$BR$Events", + "LD0/LLN0$BR$Events", + "LD0", + "LLN0$Events", + 17U, + {0x7FU, 0x80U}, + 20U, + 0x70U}; + +const mms::MmsStaticBrcbDefinition kDifferentDefinition{ + "LD0", + "LLN0$BR$Events", + "LD0/LLN0$BR$Different", + "LD0", + "LLN0$Events", + 17U, + {0x7FU, 0x80U}, + 20U, + 0x70U}; + +struct RuntimeFixture final { + std::array slot0{}; + std::array slot1{}; + std::array slot2{}; + std::array slots{ + mms::MmsStaticBrcbSlot{slot0}, + mms::MmsStaticBrcbSlot{slot1}, + mms::MmsStaticBrcbSlot{slot2}}; + mms::MmsStaticBrcbPendingState pending{}; + mms::MmsStaticBrcbRuntime runtime; + + explicit RuntimeFixture(const mms::MmsStaticBrcbDefinition& definition) noexcept + : runtime{definition, pending, slots, kObjectTable, kDataSetTable} {} +}; + +struct SmallRuntimeFixture final { + std::array slot0{}; + std::array slots{ + mms::MmsStaticBrcbSlot{slot0}}; + mms::MmsStaticBrcbPendingState pending{}; + mms::MmsStaticBrcbRuntime runtime{ + kDefinition, pending, slots, kObjectTable, kDataSetTable}; +}; + +struct FakeStorage final { + std::array bytes{}; + std::size_t write_budget{std::numeric_limits::max()}; + bool fail_reads{}; + bool fail_erases{}; + bool fail_sync{}; + + FakeStorage() noexcept { + std::fill(bytes.begin(), bytes.end(), std::uint8_t{0xFFU}); + } +}; + +[[nodiscard]] bool storage_read( + void* context, + const std::size_t offset, + const std::span destination) noexcept { + auto* storage = static_cast(context); + if (storage == nullptr || storage->fail_reads || offset > storage->bytes.size() || + destination.size() > storage->bytes.size() - offset) { + return false; + } + std::copy_n(storage->bytes.begin() + offset, destination.size(), destination.begin()); + return true; +} + +[[nodiscard]] bool storage_write( + void* context, + const std::size_t offset, + const std::span source) noexcept { + auto* storage = static_cast(context); + if (storage == nullptr || offset > storage->bytes.size() || + source.size() > storage->bytes.size() - offset) { + return false; + } + const auto writable = std::min(source.size(), storage->write_budget); + std::copy_n(source.begin(), writable, storage->bytes.begin() + offset); + if (storage->write_budget != std::numeric_limits::max()) { + storage->write_budget -= writable; + } + return writable == source.size(); +} + +[[nodiscard]] bool storage_erase( + void* context, + const std::size_t offset, + const std::size_t bytes) noexcept { + auto* storage = static_cast(context); + if (storage == nullptr || storage->fail_erases || offset > storage->bytes.size() || + bytes > storage->bytes.size() - offset) { + return false; + } + std::fill_n(storage->bytes.begin() + offset, bytes, std::uint8_t{0xFFU}); + return true; +} + +[[nodiscard]] bool storage_sync(void* context) noexcept { + const auto* storage = static_cast(context); + return storage != nullptr && !storage->fail_sync; +} + +[[nodiscard]] bool capture_one( + mms::MmsStaticBrcbRuntime& runtime, + const std::uint64_t now_ms, + const std::span staging, + const std::span workspace) noexcept { + if (runtime.notify(0U, mms::MmsStaticBrcbEventReason::data_change, now_ms) != + mms::MmsStaticBrcbStatus::ok) { + return false; + } + mms::MmsStaticBrcbCapturePlan plan; + if (!runtime.next_due(now_ms + 20U, plan)) { + return false; + } + return runtime.capture(plan, kReportTime, staging, workspace).success(); +} + +[[nodiscard]] bool entry_is( + const mms::MmsStaticBrcbEntryView& entry, + const std::uint8_t low_byte) noexcept { + return entry.entry_id.size() == 8U && entry.entry_id[7U] == low_byte; +} + +} // namespace + +int main() { + FakeStorage storage; + const mms::MmsStaticBrcbStorageBackend backend{ + &storage, + storage.bytes.size(), + storage_read, + storage_write, + storage_erase, + storage_sync}; + const mms::MmsStaticBrcbCheckpointStore store{backend, kBankBytes}; + if (!store.valid() || store.maximum_state_bytes() == 0U) { + return 1; + } + + std::array state_buffer{}; + std::array staging{}; + std::array workspace{}; + + RuntimeFixture empty{kDefinition}; + if (!empty.runtime.initialize() || + store.restore(empty.runtime, state_buffer).status != + mms::MmsStaticBrcbCheckpointStatus::no_checkpoint) { + return 2; + } + + RuntimeFixture source{kDefinition}; + if (!source.runtime.initialize() || + source.runtime.set_enabled(true) != mms::MmsStaticBrcbStatus::ok) { + return 3; + } + g_value = true; + if (!capture_one(source.runtime, 100U, staging, workspace)) { + return 4; + } + g_value = false; + if (!capture_one(source.runtime, 200U, staging, workspace) || + source.runtime.queue_size() != 2U) { + return 5; + } + + mms::MmsStaticBrcbEntryView source_front; + std::array expected_front{}; + std::array expected_entry_id{}; + if (!source.runtime.front(source_front) || source_front.mms_pdu.size() > expected_front.size()) { + return 6; + } + const auto expected_front_bytes = source_front.mms_pdu.size(); + std::copy(source_front.mms_pdu.begin(), source_front.mms_pdu.end(), expected_front.begin()); + std::copy(source_front.entry_id.begin(), source_front.entry_id.end(), expected_entry_id.begin()); + + std::array tiny_state{}; + const auto tiny_checkpoint = store.checkpoint(source.runtime, tiny_state); + if (tiny_checkpoint.status != mms::MmsStaticBrcbCheckpointStatus::state_buffer_too_small || + tiny_checkpoint.required_state_bytes <= tiny_state.size()) { + return 7; + } + + const auto first_checkpoint = store.checkpoint(source.runtime, state_buffer); + if (!first_checkpoint.success() || first_checkpoint.generation != 1U || + first_checkpoint.bank_index != 0U || first_checkpoint.state_bytes == 0U) { + return 8; + } + + RuntimeFixture recovered{kDefinition}; + if (!recovered.runtime.initialize()) { + return 9; + } + const auto first_restore = store.restore(recovered.runtime, state_buffer); + mms::MmsStaticBrcbEntryView recovered_front; + if (!first_restore.success() || first_restore.generation != 1U || + recovered.runtime.enabled() || recovered.runtime.queue_size() != 2U || + !recovered.runtime.front(recovered_front) || + recovered_front.mms_pdu.size() != expected_front_bytes || + !std::equal( + recovered_front.mms_pdu.begin(), + recovered_front.mms_pdu.end(), + expected_front.begin()) || + !std::equal( + recovered_front.entry_id.begin(), + recovered_front.entry_id.end(), + expected_entry_id.begin())) { + return 10; + } + + RuntimeFixture wrong_definition{kDifferentDefinition}; + if (!wrong_definition.runtime.initialize()) { + return 11; + } + const auto mismatch = store.restore(wrong_definition.runtime, state_buffer); + if (mismatch.status != mms::MmsStaticBrcbCheckpointStatus::state_restore_failed || + mismatch.state_status != mms::MmsStaticBrcbStateStatus::definition_mismatch || + wrong_definition.runtime.queue_size() != 0U) { + return 12; + } + + SmallRuntimeFixture too_small; + if (!too_small.runtime.initialize()) { + return 13; + } + const auto capacity = store.restore(too_small.runtime, state_buffer); + if (capacity.status != mms::MmsStaticBrcbCheckpointStatus::state_restore_failed || + capacity.state_status != mms::MmsStaticBrcbStateStatus::capacity_mismatch || + too_small.runtime.queue_size() != 0U) { + return 14; + } + + // Build generation 2: deliver EntryID 1, then append EntryID 3. + if (!entry_is(recovered_front, 1U) || + recovered.runtime.commit_delivery(recovered_front.entry_id) != + mms::MmsStaticBrcbStatus::ok || + recovered.runtime.set_enabled(true) != mms::MmsStaticBrcbStatus::ok) { + return 15; + } + g_value = true; + if (!capture_one(recovered.runtime, 300U, staging, workspace) || + recovered.runtime.queue_size() != 2U || + !recovered.runtime.front(recovered_front) || !entry_is(recovered_front, 2U)) { + return 16; + } + const auto second_checkpoint = store.checkpoint(recovered.runtime, state_buffer); + if (!second_checkpoint.success() || second_checkpoint.generation != 2U || + second_checkpoint.bank_index != 1U) { + return 17; + } + + // Queue now advances to EntryID 4, but checkpoint generation 3 is torn + // while writing the final commit footer. Generation 2 must survive reboot. + g_value = false; + if (!capture_one(recovered.runtime, 400U, staging, workspace) || + recovered.runtime.queue_size() != 3U) { + return 18; + } + const auto projected = mms::MmsStaticBrcbStateCodec::encode( + recovered.runtime, state_buffer); + if (!projected.success()) { + return 19; + } + storage.write_budget = + mms::MmsStaticBrcbCheckpointStore::record_header_bytes + + projected.bytes_written + 8U; + const auto torn = store.checkpoint(recovered.runtime, state_buffer); + if (torn.status != mms::MmsStaticBrcbCheckpointStatus::storage_failure || + torn.generation != 3U || torn.bank_index != 0U) { + return 20; + } + storage.write_budget = std::numeric_limits::max(); + + RuntimeFixture after_torn{kDefinition}; + if (!after_torn.runtime.initialize()) { + return 21; + } + const auto torn_restore = store.restore(after_torn.runtime, state_buffer); + mms::MmsStaticBrcbEntryView torn_front; + if (!torn_restore.success() || torn_restore.generation != 2U || + torn_restore.bank_index != 1U || after_torn.runtime.queue_size() != 2U || + !after_torn.runtime.front(torn_front) || !entry_is(torn_front, 2U)) { + return 22; + } + + // Retry generation 3 successfully. The store alternates back to bank 0. + const auto third_checkpoint = store.checkpoint(recovered.runtime, state_buffer); + if (!third_checkpoint.success() || third_checkpoint.generation != 3U || + third_checkpoint.bank_index != 0U) { + return 23; + } + + // Corrupt the newest payload. Recovery must reject bank 0 by CRC and fall + // back to still-valid generation 2 in bank 1. + storage.bytes[mms::MmsStaticBrcbCheckpointStore::record_header_bytes + 10U] ^= + std::uint8_t{0x01U}; + RuntimeFixture after_corruption{kDefinition}; + if (!after_corruption.runtime.initialize()) { + return 24; + } + const auto corrupt_restore = store.restore(after_corruption.runtime, state_buffer); + if (!corrupt_restore.success() || corrupt_restore.generation != 2U || + corrupt_restore.bank_index != 1U || after_corruption.runtime.queue_size() != 2U || + !after_corruption.runtime.front(torn_front) || !entry_is(torn_front, 2U)) { + return 25; + } + + // Erasing both banks produces a clean no-checkpoint state, not partial data. + std::fill(storage.bytes.begin(), storage.bytes.end(), std::uint8_t{0xFFU}); + RuntimeFixture erased{kDefinition}; + if (!erased.runtime.initialize() || + store.restore(erased.runtime, state_buffer).status != + mms::MmsStaticBrcbCheckpointStatus::no_checkpoint || + erased.runtime.queue_size() != 0U) { + return 26; + } + + return 0; +} diff --git a/embedded/mms_connection_runtime_hard_profile_smoke.cpp b/embedded/mms_connection_runtime_hard_profile_smoke.cpp new file mode 100644 index 000000000..e4202ce0d --- /dev/null +++ b/embedded/mms_connection_runtime_hard_profile_smoke.cpp @@ -0,0 +1,418 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/connection_runtime.hpp" +#include "ariec61850/mms/services_span.hpp" +#include "ariec61850/mms/static_object_table.hpp" +#include "ariec61850/osi/cotp_span.hpp" +#include "ariec61850/osi/presentation_span.hpp" +#include "ariec61850/osi/session_span.hpp" +#include "ariec61850/osi/tpkt_span.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kInt32Type{0x85U, 0x01U, 0x20U}; + +constexpr std::array kAssociationRequest{ + 0x0DU, 0xB6U, 0x05U, 0x06U, 0x13U, 0x01U, 0x00U, 0x16U, 0x01U, 0x02U, 0x14U, 0x02U, + 0x00U, 0x02U, 0x33U, 0x02U, 0x00U, 0x01U, 0x34U, 0x02U, 0x00U, 0x01U, 0xC1U, 0xA0U, + 0x31U, 0x81U, 0x9DU, 0xA0U, 0x03U, 0x80U, 0x01U, 0x01U, 0xA2U, 0x81U, 0x95U, 0x81U, + 0x04U, 0x00U, 0x00U, 0x00U, 0x01U, 0x82U, 0x04U, 0x00U, 0x00U, 0x00U, 0x01U, 0xA4U, + 0x23U, 0x30U, 0x0FU, 0x02U, 0x01U, 0x01U, 0x06U, 0x04U, 0x52U, 0x01U, 0x00U, 0x01U, + 0x30U, 0x04U, 0x06U, 0x02U, 0x51U, 0x01U, 0x30U, 0x10U, 0x02U, 0x01U, 0x03U, 0x06U, + 0x05U, 0x28U, 0xCAU, 0x22U, 0x02U, 0x01U, 0x30U, 0x04U, 0x06U, 0x02U, 0x51U, 0x01U, + 0x61U, 0x62U, 0x30U, 0x60U, 0x02U, 0x01U, 0x01U, 0xA0U, 0x5BU, 0x60U, 0x59U, 0xA1U, + 0x07U, 0x06U, 0x05U, 0x28U, 0xCAU, 0x22U, 0x02U, 0x03U, 0xA2U, 0x07U, 0x06U, 0x05U, + 0x29U, 0x01U, 0x87U, 0x67U, 0x01U, 0xA3U, 0x03U, 0x02U, 0x01U, 0x0CU, 0xA6U, 0x06U, + 0x06U, 0x04U, 0x29U, 0x01U, 0x87U, 0x67U, 0xA7U, 0x03U, 0x02U, 0x01U, 0x0CU, 0xBEU, + 0x33U, 0x28U, 0x31U, 0x06U, 0x02U, 0x51U, 0x01U, 0x02U, 0x01U, 0x03U, 0xA0U, 0x28U, + 0xA8U, 0x26U, 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, 0x81U, 0x01U, 0x0AU, 0x82U, 0x01U, + 0x0AU, 0x83U, 0x01U, 0x05U, 0xA4U, 0x16U, 0x80U, 0x01U, 0x01U, 0x81U, 0x03U, 0x05U, + 0xF1U, 0x00U, 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, 0x08U, 0x00U, + 0x00U, 0x79U, 0xEFU, 0x18U}; + +constexpr std::array kAssociationResponse{ + 0x0EU, 0x88U, 0x05U, 0x06U, 0x13U, 0x01U, 0x00U, 0x16U, 0x01U, 0x02U, 0x14U, 0x02U, + 0x00U, 0x02U, 0x33U, 0x02U, 0x00U, 0x01U, 0x34U, 0x02U, 0x00U, 0x01U, 0xC1U, 0x72U, + 0x31U, 0x70U, 0xA0U, 0x03U, 0x80U, 0x01U, 0x01U, 0xA2U, 0x69U, 0xA5U, 0x12U, 0x30U, + 0x07U, 0x80U, 0x01U, 0x00U, 0x81U, 0x02U, 0x51U, 0x01U, 0x30U, 0x07U, 0x80U, 0x01U, + 0x00U, 0x81U, 0x02U, 0x51U, 0x01U, 0x61U, 0x53U, 0x30U, 0x51U, 0x02U, 0x01U, 0x01U, + 0xA0U, 0x4CU, 0x61U, 0x4AU, 0xA1U, 0x07U, 0x06U, 0x05U, 0x28U, 0xCAU, 0x22U, 0x02U, + 0x03U, 0xA2U, 0x03U, 0x02U, 0x01U, 0x00U, 0xA3U, 0x05U, 0xA1U, 0x03U, 0x02U, 0x01U, + 0x00U, 0xBEU, 0x33U, 0x28U, 0x31U, 0x06U, 0x02U, 0x51U, 0x01U, 0x02U, 0x01U, 0x03U, + 0xA0U, 0x28U, 0xA9U, 0x26U, 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, 0x81U, 0x01U, 0x0AU, + 0x82U, 0x01U, 0x0AU, 0x83U, 0x01U, 0x05U, 0xA4U, 0x16U, 0x80U, 0x01U, 0x01U, 0x81U, + 0x03U, 0x05U, 0xF1U, 0x00U, 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, + 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U}; + +constexpr std::array kReadRequest{ + 0xA0U, 0x27U, 0x02U, 0x01U, 0x0CU, + 0xA4U, 0x22U, 0xA1U, 0x20U, 0xA0U, 0x1EU, + 0x30U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x52U, 0x31U, + 0x30U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x4DU, 0x31U}; + +constexpr std::array kReadResponse{ + 0xA1U, 0x0DU, 0x02U, 0x01U, 0x0CU, + 0xA4U, 0x08U, 0xA1U, 0x06U, + 0x83U, 0x01U, 0xFFU, + 0x85U, 0x01U, 0x2AU}; + +constexpr std::array kWriteRequest{ + 0xA0U, 0x1BU, 0x02U, 0x01U, 0x0EU, + 0xA5U, 0x16U, + 0xA0U, 0x0FU, + 0x30U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x52U, 0x31U, + 0xA0U, 0x03U, 0x83U, 0x01U, 0x00U}; + +constexpr std::array kWriteResponse{ + 0xA1U, 0x07U, 0x02U, 0x01U, 0x0EU, + 0xA5U, 0x02U, 0x81U, 0x00U}; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + const auto value = *static_cast(context); + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = value ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] wire::EncodeResult read_small_int32( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + const auto value = *static_cast(context); + if (value > 0x7FU) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + destination[0] = 0x85U; + destination[1] = 0x01U; + destination[2] = value; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] mms::MmsStaticWriteResult write_boolean( + void* context, + const std::span encoded_data) noexcept { + if (context == nullptr || encoded_data.size() != 3U || + encoded_data[0] != 0x83U || encoded_data[1] != 0x01U || + (encoded_data[2] != 0x00U && encoded_data[2] != 0xFFU)) { + return {false, 7U}; + } + *static_cast(context) = encoded_data[2] != 0x00U; + return {true, 0U}; +} + +template +[[nodiscard]] wire::EncodeResult build_data_tpkt( + const std::span session_payload, + std::array& output, + std::array& scratch, + const bool eot = true) noexcept { + const auto cotp = osi::CotpSpanCodec::encode_data_into( + session_payload, scratch, eot, 0U); + if (!cotp.success()) { + return cotp; + } + return osi::TpktSpanCodec::encode_into( + std::span{scratch}.first(cotp.bytes_written), output); +} + +template +[[nodiscard]] wire::EncodeResult build_mms_tpkt( + const std::span mms_pdu, + std::array& output, + std::array& presentation, + std::array& scratch, + const bool eot = true) noexcept { + const auto p_data = osi::PresentationSpanCodec::encode_p_data_into( + mms_pdu, presentation, 3U, true); + if (!p_data.success()) { + return p_data; + } + return build_data_tpkt( + std::span{presentation}.first(p_data.bytes_written), + output, + scratch, + eot); +} + +[[nodiscard]] bool extract_cotp( + const std::span frame, + osi::CotpTpduView& cotp) noexcept { + osi::TpktFrameView tpkt; + return osi::TpktSpanCodec::try_decode_view(frame, tpkt) && + osi::CotpSpanCodec::try_decode_view(tpkt.payload, cotp); +} + +[[nodiscard]] bool extract_mms( + const std::span frame, + osi::PresentationPdvView& pdv) noexcept { + osi::CotpTpduView cotp; + osi::SessionDataTransferView session; + return extract_cotp(frame, cotp) && + cotp.kind == osi::CotpWireKind::data && + osi::SessionSpanCodec::try_decode_data_transfer_view(cotp.user_data, session) && + osi::PresentationSpanCodec::try_decode_fully_encoded_data_view( + session.presentation_payload, pdv); +} + +} // namespace + +int main() { + bool relay_state = true; + const std::uint8_t meter_value = 42U; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "R1", kBooleanType, + read_boolean, &relay_state, false, + write_boolean, &relay_state}, + mms::MmsStaticObjectEntry{ + "LD0", "M1", kInt32Type, + read_small_int32, &meter_value, false}}; + const mms::MmsStaticObjectTable table{objects}; + const mms::MmsStaticApplicationDispatcher dispatcher{table}; + mms::MmsStaticConnectionRuntime runtime{dispatcher}; + if (!table.valid() || + runtime.state() != mms::MmsStaticConnectionState::awaiting_cotp_connect) { + return 1; + } + + constexpr std::array tpdu_size{0x0AU}; + constexpr std::array source_tsap{0x00U, 0x01U}; + constexpr std::array destination_tsap{0x00U, 0x01U}; + const std::array parameters{ + osi::CotpParameterView{osi::CotpSpanCodec::tpdu_size_parameter, tpdu_size}, + osi::CotpParameterView{osi::CotpSpanCodec::source_tsap_parameter, source_tsap}, + osi::CotpParameterView{osi::CotpSpanCodec::destination_tsap_parameter, destination_tsap}}; + + std::array request{}; + std::array response{}; + std::array workspace{}; + std::array scratch{}; + std::array presentation{}; + + const auto cr = osi::CotpSpanCodec::encode_connection_request_into( + 0x0001U, parameters, scratch); + const auto cr_tpkt = cr.success() + ? osi::TpktSpanCodec::encode_into( + std::span{scratch}.first(cr.bytes_written), request) + : wire::EncodeResult{}; + if (!cr.success() || !cr_tpkt.success()) { + return 2; + } + + auto result = runtime.process_tcp_window( + std::span{request}.first(3U), response, workspace); + if (result.status != mms::MmsStaticConnectionStatus::need_more || + result.consumed_bytes != 0U || + runtime.state() != mms::MmsStaticConnectionState::awaiting_cotp_connect) { + return 3; + } + + result = runtime.process_tcp_window( + std::span{request}.first(cr_tpkt.bytes_written), + response, + workspace); + osi::CotpTpduView cc; + if (!result.response_ready() || result.consumed_bytes != cr_tpkt.bytes_written || + runtime.state() != mms::MmsStaticConnectionState::awaiting_association || + !extract_cotp( + std::span{response}.first(result.bytes_written), cc) || + cc.kind != osi::CotpWireKind::connection_confirm || + cc.destination_reference != 0x0001U || cc.source_reference != 0x1001U) { + return 4; + } + + const auto association_tpkt = build_data_tpkt( + kAssociationRequest, request, scratch); + result = runtime.process_tcp_window( + std::span{request}.first(association_tpkt.bytes_written), + response, + workspace); + osi::CotpTpduView association_response; + if (!association_tpkt.success() || !result.response_ready() || + runtime.state() != mms::MmsStaticConnectionState::established || + runtime.mms_presentation_context_id() != 3U || + !extract_cotp( + std::span{response}.first(result.bytes_written), + association_response) || + !matches(association_response.user_data, kAssociationResponse)) { + return 5; + } + + const auto read_tpkt = build_mms_tpkt( + kReadRequest, request, presentation, scratch); + if (!read_tpkt.success()) { + return 6; + } + + std::array coalesced{}; + std::copy_n(request.begin(), read_tpkt.bytes_written, coalesced.begin()); + std::copy_n( + request.begin(), + read_tpkt.bytes_written, + coalesced.begin() + static_cast(read_tpkt.bytes_written)); + const auto coalesced_bytes = read_tpkt.bytes_written * 2U; + + result = runtime.process_tcp_window( + std::span{coalesced}.first(coalesced_bytes), + response, + workspace); + osi::PresentationPdvView pdv; + if (!result.response_ready() || result.consumed_bytes != read_tpkt.bytes_written || + result.application_service != mms::MmsWireConfirmedService::read || + !extract_mms( + std::span{response}.first(result.bytes_written), pdv) || + pdv.context_id != 3U || !matches(pdv.single_asn1_type, kReadResponse)) { + return 7; + } + + const auto second_window = std::span{coalesced} + .subspan(result.consumed_bytes, read_tpkt.bytes_written); + result = runtime.process_tcp_window(second_window, response, workspace); + if (!result.response_ready() || result.consumed_bytes != read_tpkt.bytes_written || + !extract_mms( + std::span{response}.first(result.bytes_written), pdv) || + !matches(pdv.single_asn1_type, kReadResponse)) { + return 8; + } + + for (std::uint32_t iteration = 0U; iteration < 10'000U; ++iteration) { + result = runtime.process_tcp_window( + std::span{request}.first(read_tpkt.bytes_written), + response, + workspace); + if (!result.response_ready() || + result.application_service != mms::MmsWireConfirmedService::read || + result.invoke_id != 12U) { + return 9; + } + } + + std::array identify_mms{}; + const auto identify = mms::MmsPduSpanCodec::encode_confirmed_request_into( + 21U, + static_cast(mms::MmsWireConfirmedService::identify), + false, + {}, + identify_mms); + const auto identify_tpkt = identify.success() + ? build_mms_tpkt( + std::span{identify_mms}.first(identify.bytes_written), + request, + presentation, + scratch) + : wire::EncodeResult{}; + if (!identify.success() || !identify_tpkt.success()) { + return 10; + } + result = runtime.process_tcp_window( + std::span{request}.first(identify_tpkt.bytes_written), + response, + workspace); + if (result.status != mms::MmsStaticConnectionStatus::application_rejected || + result.application_status != mms::MmsStaticDispatchStatus::unsupported_service || + result.application_service != mms::MmsWireConfirmedService::identify || + result.invoke_id != 21U || result.consumed_bytes != identify_tpkt.bytes_written || + runtime.state() != mms::MmsStaticConnectionState::established) { + return 11; + } + + const auto write_tpkt = build_mms_tpkt( + kWriteRequest, request, presentation, scratch); + if (!write_tpkt.success() || !relay_state) { + return 12; + } + std::array small_response{}; + result = runtime.process_tcp_window( + std::span{request}.first(write_tpkt.bytes_written), + small_response, + workspace); + if (result.status != mms::MmsStaticConnectionStatus::response_buffer_too_small || + result.consumed_bytes != 0U || result.required_response_bytes <= small_response.size() || + !relay_state || runtime.state() != mms::MmsStaticConnectionState::established) { + return 13; + } + + result = runtime.process_tcp_window( + std::span{request}.first(write_tpkt.bytes_written), + response, + workspace); + if (!result.response_ready() || relay_state || + result.application_service != mms::MmsWireConfirmedService::write || + !extract_mms( + std::span{response}.first(result.bytes_written), pdv) || + !matches(pdv.single_asn1_type, kWriteResponse)) { + return 14; + } + + const auto segmented_tpkt = build_mms_tpkt( + kReadRequest, request, presentation, scratch, false); + result = runtime.process_tcp_window( + std::span{request}.first(segmented_tpkt.bytes_written), + response, + workspace); + if (!segmented_tpkt.success() || + result.status != mms::MmsStaticConnectionStatus::protocol_violation || + runtime.state() != mms::MmsStaticConnectionState::fault) { + return 15; + } + + runtime.reset(); + if (runtime.state() != mms::MmsStaticConnectionState::awaiting_cotp_connect || + runtime.mms_presentation_context_id() != 0U) { + return 16; + } + + mms::MmsStaticConnectionRuntime wrong_order{dispatcher}; + const auto out_of_order = build_mms_tpkt( + kReadRequest, request, presentation, scratch); + result = wrong_order.process_tcp_window( + std::span{request}.first(out_of_order.bytes_written), + response, + workspace); + if (!out_of_order.success() || + result.status != mms::MmsStaticConnectionStatus::protocol_violation || + wrong_order.state() != mms::MmsStaticConnectionState::fault) { + return 17; + } + + return 0; +} diff --git a/embedded/mms_data_set_hard_profile_smoke.cpp b/embedded/mms_data_set_hard_profile_smoke.cpp new file mode 100644 index 000000000..958e67910 --- /dev/null +++ b/embedded/mms_data_set_hard_profile_smoke.cpp @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/data_set_span.hpp" +#include "ariec61850/mms/static_data_set_table.hpp" +#include "ariec61850/mms/static_dispatcher.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kAttributesRequest{ + 0xA0U, 0x19U, 0x02U, 0x01U, 0x01U, + 0xACU, 0x14U, 0xA1U, 0x12U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x0BU, 0x4CU, 0x4CU, 0x4EU, 0x30U, 0x24U, + 0x45U, 0x76U, 0x65U, 0x6EU, 0x74U, 0x73U}; +constexpr std::array kAttributesResponse{ + 0xA1U, 0x28U, 0x02U, 0x01U, 0x01U, + 0xACU, 0x23U, 0x80U, 0x01U, 0x00U, + 0xA1U, 0x1EU, + 0x30U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x52U, 0x31U, + 0x30U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x4DU, 0x31U}; +constexpr std::array kDataSetNameListRequest{ + 0xA0U, 0x11U, 0x02U, 0x01U, 0x02U, + 0xA1U, 0x0CU, + 0xA0U, 0x03U, 0x80U, 0x01U, 0x02U, + 0xA1U, 0x05U, 0x81U, 0x03U, 0x4CU, 0x44U, 0x30U}; +constexpr std::array kDataSetNameListResponse{ + 0xA1U, 0x17U, 0x02U, 0x01U, 0x02U, + 0xA1U, 0x12U, 0xA0U, 0x0DU, + 0x1AU, 0x0BU, 0x4CU, 0x4CU, 0x4EU, 0x30U, 0x24U, + 0x45U, 0x76U, 0x65U, 0x6EU, 0x74U, 0x73U, + 0x81U, 0x01U, 0x00U}; +constexpr std::array kLd0{0x4CU, 0x44U, 0x30U}; +constexpr std::array kR1{0x52U, 0x31U}; +constexpr std::array kM1{0x4DU, 0x31U}; +constexpr std::array kEvents{ + 0x4CU, 0x4CU, 0x4EU, 0x30U, 0x24U, + 0x45U, 0x76U, 0x65U, 0x6EU, 0x74U, 0x73U}; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = *static_cast(context) ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +} // namespace + +int main() { + mms::MmsNamedVariableListAttributesRequestView request; + if (!mms::MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_request( + kAttributesRequest, request) || + request.invoke_id != 1U || + request.name.kind != mms::MmsObjectNameViewKind::domain_specific || + !matches(request.name.domain, kLd0) || !matches(request.name.item, kEvents)) { + return 1; + } + + const std::array encoded_members{ + mms::MmsNamedVariableListMemberInput{"LD0", "R1"}, + mms::MmsNamedVariableListMemberInput{"LD0", "M1"}}; + std::array buffer{}; + const auto encoded = + mms::MmsDataSetSpanCodec::encode_get_named_variable_list_attributes_response_into( + 1U, false, encoded_members, buffer); + if (!encoded.success() || encoded.bytes_written != kAttributesResponse.size() || + !matches( + std::span{buffer}.first(encoded.bytes_written), + kAttributesResponse)) { + return 2; + } + + mms::MmsNamedVariableListAttributesResponseView response; + if (!mms::MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_response( + kAttributesResponse, response) || + response.invoke_id != 1U || response.mms_deletable || response.member_count != 2U) { + return 3; + } + mms::MmsObjectNameView member; + if (!response.try_member(0U, member) || + !matches(member.domain, kLd0) || !matches(member.item, kR1) || + !response.try_member(1U, member) || + !matches(member.domain, kLd0) || !matches(member.item, kM1) || + response.try_member(2U, member)) { + return 4; + } + + const bool relay_state = true; + const bool meter_state = false; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "R1", kBooleanType, read_boolean, &relay_state, false}, + mms::MmsStaticObjectEntry{ + "LD0", "M1", kBooleanType, read_boolean, &meter_state, false}}; + const mms::MmsStaticObjectTable object_table{objects}; + + const std::array event_members{ + mms::MmsStaticDataSetMember{"LD0", "R1"}, + mms::MmsStaticDataSetMember{"LD0", "M1"}}; + const std::array data_sets{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", event_members, false}}; + const mms::MmsStaticDataSetTable data_set_table{data_sets}; + if (!data_set_table.valid() || !data_set_table.valid_against(object_table)) { + return 5; + } + + const mms::MmsStaticApplicationDispatcher dispatcher{object_table, data_set_table}; + std::array workspace{}; + auto dispatched = dispatcher.dispatch(kAttributesRequest, buffer, workspace); + if (!dispatched.success() || + dispatched.service != mms::MmsWireConfirmedService::get_named_variable_list_attributes || + dispatched.invoke_id != 1U || dispatched.bytes_written != kAttributesResponse.size() || + !matches( + std::span{buffer}.first(dispatched.bytes_written), + kAttributesResponse)) { + return 6; + } + + dispatched = dispatcher.dispatch(kDataSetNameListRequest, buffer, workspace); + if (!dispatched.success() || + dispatched.service != mms::MmsWireConfirmedService::get_name_list || + dispatched.bytes_written != kDataSetNameListResponse.size() || + !matches( + std::span{buffer}.first(dispatched.bytes_written), + kDataSetNameListResponse)) { + return 7; + } + + mms::MmsGetNameListResponseView names; + std::span identifier; + if (!mms::MmsServiceSpanCodec::try_decode_get_name_list_response( + std::span{buffer}.first(dispatched.bytes_written), names) || + names.identifier_count != 1U || names.more_follows || + !names.try_identifier(0U, identifier) || !matches(identifier, kEvents)) { + return 8; + } + + std::array tiny{}; + dispatched = dispatcher.dispatch(kAttributesRequest, tiny, workspace); + if (dispatched.status != mms::MmsStaticDispatchStatus::response_buffer_too_small || + dispatched.required_bytes != kAttributesResponse.size()) { + return 9; + } + + auto missing_request = kAttributesRequest; + missing_request[26] = 0x58U; + dispatched = dispatcher.dispatch(missing_request, buffer, workspace); + if (dispatched.status != mms::MmsStaticDispatchStatus::object_not_found) { + return 10; + } + + const std::array invalid_members{ + mms::MmsStaticDataSetMember{"LD0", "X1"}}; + const std::array invalid_data_sets{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Broken", invalid_members, false}}; + const mms::MmsStaticDataSetTable invalid_table{invalid_data_sets}; + if (!invalid_table.valid() || invalid_table.valid_against(object_table)) { + return 11; + } + + for (std::uint32_t iteration = 0U; iteration < 50'000U; ++iteration) { + const auto invoke = iteration & 0x7FFFU; + const auto repeated = + mms::MmsDataSetSpanCodec::encode_get_named_variable_list_attributes_response_into( + invoke, (iteration & 1U) != 0U, encoded_members, buffer); + mms::MmsNamedVariableListAttributesResponseView decoded; + if (!repeated.success() || + !mms::MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_response( + std::span{buffer}.first(repeated.bytes_written), decoded) || + decoded.invoke_id != invoke || decoded.member_count != 2U || + decoded.mms_deletable != ((iteration & 1U) != 0U)) { + return 12; + } + } + + const std::array invalid_name{ + mms::MmsNamedVariableListMemberInput{"LD0", std::string_view{"A\0B", 3U}}}; + if (mms::MmsDataSetSpanCodec::encode_get_named_variable_list_attributes_response_into( + 1U, false, invalid_name, buffer).success()) { + return 13; + } + + return 0; +} diff --git a/embedded/mms_dispatcher_hard_profile_smoke.cpp b/embedded/mms_dispatcher_hard_profile_smoke.cpp new file mode 100644 index 000000000..37b9eef63 --- /dev/null +++ b/embedded/mms_dispatcher_hard_profile_smoke.cpp @@ -0,0 +1,320 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_dispatcher.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kInt32Type{0x85U, 0x01U, 0x20U}; + +constexpr std::array kDomainListRequest{ + 0xA0U, 0x0EU, 0x02U, 0x01U, 0x07U, + 0xA1U, 0x09U, + 0xA0U, 0x03U, 0x80U, 0x01U, 0x09U, + 0xA1U, 0x02U, 0x80U, 0x00U}; + +constexpr std::array kDomainContinueRequest{ + 0xA0U, 0x13U, 0x02U, 0x01U, 0x08U, + 0xA1U, 0x0EU, + 0xA0U, 0x03U, 0x80U, 0x01U, 0x09U, + 0xA1U, 0x02U, 0x80U, 0x00U, + 0x82U, 0x03U, 0x4CU, 0x44U, 0x30U}; + +constexpr std::array kDomainFirstResponse{ + 0xA1U, 0x0FU, 0x02U, 0x01U, 0x07U, + 0xA1U, 0x0AU, 0xA0U, 0x05U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x81U, 0x01U, 0xFFU}; + +constexpr std::array kDomainSecondResponse{ + 0xA1U, 0x0FU, 0x02U, 0x01U, 0x08U, + 0xA1U, 0x0AU, 0xA0U, 0x05U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x31U, + 0x81U, 0x01U, 0x00U}; + +constexpr std::array kAttributesRequest{ + 0xA0U, 0x12U, 0x02U, 0x01U, 0x0BU, + 0xA6U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x52U, 0x31U}; + +constexpr std::array kAttributesResponse{ + 0xA1U, 0x0CU, 0x02U, 0x01U, 0x0BU, + 0xA6U, 0x07U, 0x80U, 0x01U, 0x00U, + 0xA2U, 0x02U, 0x83U, 0x00U}; + +constexpr std::array kReadRequest{ + 0xA0U, 0x27U, 0x02U, 0x01U, 0x0CU, + 0xA4U, 0x22U, 0xA1U, 0x20U, 0xA0U, 0x1EU, + 0x30U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x52U, 0x31U, + 0x30U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x4DU, 0x31U}; + +constexpr std::array kReadResponse{ + 0xA1U, 0x0DU, 0x02U, 0x01U, 0x0CU, + 0xA4U, 0x08U, 0xA1U, 0x06U, + 0x83U, 0x01U, 0xFFU, + 0x85U, 0x01U, 0x2AU}; + +constexpr std::array kWriteRequest{ + 0xA0U, 0x1BU, 0x02U, 0x01U, 0x0EU, + 0xA5U, 0x16U, + 0xA0U, 0x0FU, + 0x30U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x52U, 0x31U, + 0xA0U, 0x03U, 0x83U, 0x01U, 0x00U}; + +constexpr std::array kWriteSuccessResponse{ + 0xA1U, 0x07U, 0x02U, 0x01U, 0x0EU, + 0xA5U, 0x02U, 0x81U, 0x00U}; + +constexpr std::array kWriteDeniedResponse{ + 0xA1U, 0x08U, 0x02U, 0x01U, 0x0FU, + 0xA5U, 0x03U, 0x80U, 0x01U, 0x03U}; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + const auto value = *static_cast(context); + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = value ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] wire::EncodeResult read_small_int32( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr || destination.size() < required) { + return context == nullptr + ? wire::EncodeResult{wire::EncodeStatus::value_out_of_range, 0U, required} + : wire::EncodeResult{wire::EncodeStatus::buffer_too_small, 0U, required}; + } + const auto value = *static_cast(context); + if (value > 0x7FU) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + destination[0] = 0x85U; + destination[1] = 0x01U; + destination[2] = value; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] mms::MmsStaticWriteResult write_boolean( + void* context, + const std::span encoded_data) noexcept { + if (context == nullptr || encoded_data.size() != 3U || + encoded_data[0] != 0x83U || encoded_data[1] != 0x01U || + (encoded_data[2] != 0x00U && encoded_data[2] != 0xFFU)) { + return {false, 7U}; + } + *static_cast(context) = encoded_data[2] != 0x00U; + return {true, 0U}; +} + +} // namespace + +int main() { + bool relay_state = true; + const std::uint8_t meter_value = 42U; + const bool remote_state = false; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "R1", kBooleanType, + read_boolean, &relay_state, false, + write_boolean, &relay_state}, + mms::MmsStaticObjectEntry{ + "LD0", "M1", kInt32Type, + read_small_int32, &meter_value, false}, + mms::MmsStaticObjectEntry{ + "LD1", "X1", kBooleanType, + read_boolean, &remote_state, false}}; + const mms::MmsStaticObjectTable table{objects}; + if (!table.valid()) { + return 1; + } + + const mms::MmsStaticDispatchPolicy policy{ + 1U, + 1U, + 10U, + 3U, + 10U}; + const mms::MmsStaticApplicationDispatcher dispatcher{table, policy}; + std::array response{}; + std::array workspace{}; + + auto dispatched = dispatcher.dispatch(kDomainListRequest, response, workspace); + if (!dispatched.success() || + dispatched.service != mms::MmsWireConfirmedService::get_name_list || + dispatched.invoke_id != 7U || + dispatched.bytes_written != kDomainFirstResponse.size() || + !matches( + std::span{response}.first(dispatched.bytes_written), + kDomainFirstResponse)) { + return 2; + } + + dispatched = dispatcher.dispatch(kDomainContinueRequest, response, workspace); + if (!dispatched.success() || dispatched.invoke_id != 8U || + dispatched.bytes_written != kDomainSecondResponse.size() || + !matches( + std::span{response}.first(dispatched.bytes_written), + kDomainSecondResponse)) { + return 3; + } + + mms::MmsConfirmedPduView attributes_view; + if (!mms::MmsPduSpanCodec::try_decode_confirmed_request_view( + kAttributesRequest, attributes_view)) { + return 4; + } + dispatched = dispatcher.dispatch(attributes_view, response, workspace); + if (!dispatched.success() || + dispatched.service != mms::MmsWireConfirmedService::get_variable_access_attributes || + dispatched.bytes_written != kAttributesResponse.size() || + !matches( + std::span{response}.first(dispatched.bytes_written), + kAttributesResponse)) { + return 5; + } + + dispatched = dispatcher.dispatch(kReadRequest, response, workspace); + if (!dispatched.success() || + dispatched.service != mms::MmsWireConfirmedService::read || + dispatched.bytes_written != kReadResponse.size() || + !matches( + std::span{response}.first(dispatched.bytes_written), + kReadResponse)) { + return 6; + } + + std::array tiny_workspace{}; + dispatched = dispatcher.dispatch(kReadRequest, response, tiny_workspace); + if (dispatched.status != mms::MmsStaticDispatchStatus::workspace_too_small || + dispatched.required_bytes < 3U) { + return 7; + } + + auto missing_read = kReadRequest; + missing_read[39] = 0x5AU; + dispatched = dispatcher.dispatch(missing_read, response, workspace); + if (!dispatched.success()) { + return 8; + } + mms::MmsReadResponseView read_response; + mms::MmsReadAccessResultView read_result; + if (!mms::MmsServiceSpanCodec::try_decode_read_response( + std::span{response}.first(dispatched.bytes_written), + read_response) || + read_response.result_count != 2U || + !read_response.try_result(0U, read_result) || !read_result.success || + !read_response.try_result(1U, read_result) || read_result.success || + read_result.failure_code != 10U) { + return 9; + } + + dispatched = dispatcher.dispatch(kWriteRequest, response, workspace); + if (!dispatched.success() || relay_state || + dispatched.service != mms::MmsWireConfirmedService::write || + dispatched.bytes_written != kWriteSuccessResponse.size() || + !matches( + std::span{response}.first(dispatched.bytes_written), + kWriteSuccessResponse)) { + return 10; + } + + auto read_only_write = kWriteRequest; + read_only_write[4] = 0x0FU; + read_only_write[22] = 0x4DU; + dispatched = dispatcher.dispatch(read_only_write, response, workspace); + if (!dispatched.success() || + dispatched.bytes_written != kWriteDeniedResponse.size() || + !matches( + std::span{response}.first(dispatched.bytes_written), + kWriteDeniedResponse) || + meter_value != 42U) { + return 11; + } + + std::array tiny_response{}; + dispatched = dispatcher.dispatch(kDomainListRequest, tiny_response, workspace); + if (dispatched.status != mms::MmsStaticDispatchStatus::response_buffer_too_small || + dispatched.required_bytes != kDomainFirstResponse.size()) { + return 12; + } + + auto unknown_continuation = kDomainContinueRequest; + unknown_continuation[18] = 0x5AU; + unknown_continuation[19] = 0x5AU; + unknown_continuation[20] = 0x5AU; + dispatched = dispatcher.dispatch(unknown_continuation, response, workspace); + if (dispatched.status != mms::MmsStaticDispatchStatus::object_not_found) { + return 13; + } + + std::array identify_request{}; + const auto identify_encoded = mms::MmsPduSpanCodec::encode_confirmed_request_into( + 21U, + static_cast(mms::MmsWireConfirmedService::identify), + false, + {}, + identify_request); + if (!identify_encoded.success()) { + return 14; + } + dispatched = dispatcher.dispatch( + std::span{identify_request}.first(identify_encoded.bytes_written), + response, + workspace); + if (dispatched.status != mms::MmsStaticDispatchStatus::unsupported_service || + dispatched.invoke_id != 21U) { + return 15; + } + + constexpr std::array malformed{0x00U}; + dispatched = dispatcher.dispatch(malformed, response, workspace); + if (dispatched.status != mms::MmsStaticDispatchStatus::malformed_request || + dispatched.service != mms::MmsWireConfirmedService::unknown) { + return 16; + } + + for (std::uint32_t iteration = 0U; iteration < 20'000U; ++iteration) { + relay_state = true; + const auto read = dispatcher.dispatch(kReadRequest, response, workspace); + const auto write = dispatcher.dispatch(kWriteRequest, response, workspace); + if (!read.success() || !write.success() || relay_state) { + return 17; + } + } + + return 0; +} diff --git a/embedded/mms_get_name_list_hard_profile_smoke.cpp b/embedded/mms_get_name_list_hard_profile_smoke.cpp new file mode 100644 index 000000000..61c7c5e7d --- /dev/null +++ b/embedded/mms_get_name_list_hard_profile_smoke.cpp @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/services_span.hpp" +#include "ariec61850/osi/presentation_span.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kDomainListRequest{ + 0xA0U, 0x0EU, 0x02U, 0x01U, 0x07U, + 0xA1U, 0x09U, + 0xA0U, 0x03U, 0x80U, 0x01U, 0x09U, + 0xA1U, 0x02U, 0x80U, 0x00U}; + +constexpr std::array kDomainScopedRequest{ + 0xA0U, 0x14U, 0x02U, 0x01U, 0x08U, + 0xA1U, 0x0FU, + 0xA0U, 0x03U, 0x80U, 0x01U, 0x00U, + 0xA1U, 0x05U, 0x81U, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x82U, 0x01U, 0x58U}; + +constexpr std::array kResponse{ + 0xA1U, 0x14U, 0x02U, 0x01U, 0x07U, + 0xA1U, 0x0FU, + 0xA0U, 0x0AU, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x31U, + 0x81U, 0x01U, 0x00U}; + +constexpr std::array kLd0{0x4CU, 0x44U, 0x30U}; +constexpr std::array kLd1{0x4CU, 0x44U, 0x31U}; +constexpr std::array kContinuation{0x58U}; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +} // namespace + +int main() { + mms::MmsGetNameListRequestView request; + if (!mms::MmsServiceSpanCodec::try_decode_get_name_list_request( + kDomainListRequest, request) || + request.invoke_id != 7U || + request.object_class != mms::MmsNameListObjectClass::domain || + request.scope != mms::MmsNameScopeKind::vmd_specific || + !request.domain_id.empty() || !request.continue_after.empty()) { + return 1; + } + + if (!mms::MmsServiceSpanCodec::try_decode_get_name_list_request( + kDomainScopedRequest, request) || + request.invoke_id != 8U || + request.object_class != mms::MmsNameListObjectClass::named_variable || + request.scope != mms::MmsNameScopeKind::domain_specific || + !matches(request.domain_id, kLd0) || + !matches(request.continue_after, kContinuation)) { + return 2; + } + + const std::array names{"LD0", "LD1"}; + std::array buffer{}; + const auto encoded = mms::MmsServiceSpanCodec::encode_get_name_list_response_into( + 7U, names, false, buffer); + if (!encoded.success() || encoded.bytes_written != kResponse.size() || + !matches( + std::span{buffer}.first(encoded.bytes_written), + kResponse)) { + return 3; + } + + mms::MmsGetNameListResponseView response; + if (!mms::MmsServiceSpanCodec::try_decode_get_name_list_response( + kResponse, response) || + response.invoke_id != 7U || response.identifier_count != 2U || + response.more_follows) { + return 4; + } + + std::span identifier; + if (!response.try_identifier(0U, identifier) || !matches(identifier, kLd0) || + !response.try_identifier(1U, identifier) || !matches(identifier, kLd1) || + response.try_identifier(2U, identifier)) { + return 5; + } + + // Post-association application flow: response PDU -> Presentation P-DATA -> + // zero-copy Presentation view -> zero-copy GetNameList response view. + std::array p_data{}; + const auto p_data_result = osi::PresentationSpanCodec::encode_p_data_into( + kResponse, p_data, 3U, true); + osi::PresentationPdvView pdv; + if (!p_data_result.success() || + !osi::PresentationSpanCodec::try_decode_p_data_view( + std::span{p_data}.first(p_data_result.bytes_written), + pdv) || + pdv.context_id != 3U || + !mms::MmsServiceSpanCodec::try_decode_get_name_list_response( + pdv.single_asn1_type, response) || + response.identifier_count != 2U) { + return 6; + } + + // Empty lists are valid MMS responses; pagination is expressed separately + // by moreFollows. + const std::span empty_names; + const auto empty = mms::MmsServiceSpanCodec::encode_get_name_list_response_into( + 9U, empty_names, true, buffer); + if (!empty.success() || + !mms::MmsServiceSpanCodec::try_decode_get_name_list_response( + std::span{buffer}.first(empty.bytes_written), + response) || + response.identifier_count != 0U || !response.more_follows) { + return 7; + } + + for (std::uint32_t iteration = 0U; iteration < 50'000U; ++iteration) { + const auto invoke = iteration & 0x7FFFU; + const auto repeated = mms::MmsServiceSpanCodec::encode_get_name_list_response_into( + invoke, names, (iteration & 1U) != 0U, buffer); + mms::MmsGetNameListResponseView decoded; + if (!repeated.success() || + !mms::MmsServiceSpanCodec::try_decode_get_name_list_response( + std::span{buffer}.first(repeated.bytes_written), + decoded) || + decoded.invoke_id != invoke || decoded.identifier_count != 2U || + decoded.more_follows != ((iteration & 1U) != 0U)) { + return 8; + } + } + + constexpr std::array malformed_scope{ + 0xA0U, 0x0EU, 0x02U, 0x01U, 0x07U, + 0xA1U, 0x09U, + 0xA0U, 0x03U, 0x80U, 0x01U, 0x09U, + 0xA1U, 0x02U, 0x80U, 0x01U}; + if (mms::MmsServiceSpanCodec::try_decode_get_name_list_request( + malformed_scope, request)) { + return 9; + } + + const std::array invalid_name{ + std::string_view{"A\0B", 3U}}; + if (mms::MmsServiceSpanCodec::encode_get_name_list_response_into( + 1U, invalid_name, false, buffer).success()) { + return 10; + } + + return 0; +} diff --git a/embedded/mms_information_report_hard_profile_smoke.cpp b/embedded/mms_information_report_hard_profile_smoke.cpp new file mode 100644 index 000000000..7a44e5a53 --- /dev/null +++ b/embedded/mms_information_report_hard_profile_smoke.cpp @@ -0,0 +1,276 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/information_report_span.hpp" +#include "ariec61850/mms/static_data_set_table.hpp" +#include "ariec61850/mms/static_information_report.hpp" +#include "ariec61850/mms/static_object_table.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kInt32Type{0x85U, 0x01U, 0x20U}; +constexpr std::array kBooleanTrue{0x83U, 0x01U, 0x01U}; +constexpr std::array kBooleanFalse{0x83U, 0x01U, 0x00U}; +constexpr std::array kIntData{0x85U, 0x01U, 0x2AU}; +constexpr std::array kFirstReference{ + 0x8AU, 0x06U, 0x4CU, 0x44U, 0x30U, 0x2FU, 0x52U, 0x31U}; +constexpr std::array kReason{ + 0x84U, 0x02U, 0x02U, 0x08U}; +constexpr std::array kOptionalFields{0x7CU, 0x80U}; +constexpr std::array kBadSegmentationFields{0x7CU, 0xC0U}; +constexpr std::array kReportTime{ + 0x00U, 0x00U, 0x00U, 0x01U, 0x00U, 0x01U}; +constexpr std::array kLd0{0x4CU, 0x44U, 0x30U}; +constexpr std::array kEvents{ + 0x4CU, 0x4CU, 0x4EU, 0x30U, 0x24U, + 0x45U, 0x76U, 0x65U, 0x6EU, 0x74U, 0x73U}; +constexpr std::array kRpt{0x52U, 0x50U, 0x54U}; + +constexpr std::array kExpectedReport{ + 0xA3U, 0x56U, 0xA0U, 0x54U, 0xA1U, 0x05U, 0x80U, 0x03U, + 0x52U, 0x50U, 0x54U, 0xA0U, 0x4BU, 0x8AU, 0x03U, 0x72U, + 0x70U, 0x74U, 0x84U, 0x03U, 0x06U, 0x7CU, 0x80U, 0x86U, + 0x01U, 0x01U, 0x8CU, 0x06U, 0x00U, 0x00U, 0x00U, 0x01U, + 0x00U, 0x01U, 0x8AU, 0x0FU, 0x4CU, 0x44U, 0x30U, 0x2FU, + 0x4CU, 0x4CU, 0x4EU, 0x30U, 0x24U, 0x45U, 0x76U, 0x65U, + 0x6EU, 0x74U, 0x73U, 0x86U, 0x01U, 0x01U, 0x84U, 0x02U, + 0x06U, 0xC0U, 0x8AU, 0x06U, 0x4CU, 0x44U, 0x30U, 0x2FU, + 0x52U, 0x31U, 0x8AU, 0x06U, 0x4CU, 0x44U, 0x30U, 0x2FU, + 0x4DU, 0x31U, 0x83U, 0x01U, 0x01U, 0x85U, 0x01U, 0x2AU, + 0x84U, 0x02U, 0x02U, 0x08U, 0x84U, 0x02U, 0x02U, 0x08U}; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = *static_cast(context) ? 0x01U : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] wire::EncodeResult read_small_int32( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + const auto value = *static_cast(context); + if (value > 0x7FU) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x85U; + destination[1] = 0x01U; + destination[2] = value; + return {wire::EncodeStatus::ok, required, required}; +} + +} // namespace + +int main() { + const std::array references{ + mms::MmsInformationReportReferenceInput{"LD0", "R1"}, + mms::MmsInformationReportReferenceInput{"LD0", "M1"}}; + const std::array member_results{ + mms::MmsReadAccessResultInput{true, kBooleanTrue, 0U}, + mms::MmsReadAccessResultInput{true, kIntData, 0U}}; + + mms::MmsInformationReportSnapshotInput report; + report.report_id = "rpt"; + report.optional_fields = kOptionalFields; + report.sequence_number = 1U; + report.report_time = kReportTime; + report.data_set_reference = {"LD0", "LLN0$Events"}; + report.buffered = false; + report.conf_revision = 1U; + report.member_references = references; + report.member_results = member_results; + report.reason_for_inclusion = 0x08U; + + std::array buffer{}; + const auto encoded = mms::MmsInformationReportSpanCodec::encode_snapshot_into( + report, buffer); + if (!encoded.success() || encoded.bytes_written != kExpectedReport.size() || + !matches( + std::span{buffer}.first(encoded.bytes_written), + kExpectedReport)) { + return 1; + } + + mms::MmsInformationReportView decoded; + if (!mms::MmsInformationReportSpanCodec::try_decode_information_report( + std::span{buffer}.first(encoded.bytes_written), + decoded) || + decoded.variable_list_name.kind != mms::MmsObjectNameViewKind::vmd_specific || + !matches(decoded.variable_list_name.item, kRpt) || decoded.item_count != 13U) { + return 2; + } + + mms::MmsReadAccessResultView item; + if (!decoded.try_item(7U, item) || !item.success || + !matches(item.encoded_data, kFirstReference) || + !decoded.try_item(9U, item) || !item.success || + !matches(item.encoded_data, kBooleanTrue) || + !decoded.try_item(10U, item) || !item.success || + !matches(item.encoded_data, kIntData) || + !decoded.try_item(11U, item) || !item.success || + !matches(item.encoded_data, kReason) || decoded.try_item(13U, item)) { + return 3; + } + + std::array small_response{}; + const auto too_small = mms::MmsInformationReportSpanCodec::encode_snapshot_into( + report, small_response); + if (too_small.status != wire::EncodeStatus::buffer_too_small || + too_small.required_bytes != kExpectedReport.size()) { + return 4; + } + + auto invalid_report = report; + invalid_report.optional_fields = kBadSegmentationFields; + if (mms::MmsInformationReportSpanCodec::encode_snapshot_into( + invalid_report, buffer).status != wire::EncodeStatus::value_out_of_range) { + return 5; + } + + std::array failure_results{ + member_results[0], + mms::MmsReadAccessResultInput{false, {}, 10U}}; + auto failure_report = report; + failure_report.member_results = failure_results; + const auto failure_encoded = mms::MmsInformationReportSpanCodec::encode_snapshot_into( + failure_report, buffer); + if (!failure_encoded.success() || + !mms::MmsInformationReportSpanCodec::try_decode_information_report( + std::span{buffer}.first(failure_encoded.bytes_written), + decoded) || + !decoded.try_item(10U, item) || item.success || item.failure_code != 10U) { + return 6; + } + + bool relay_state = true; + std::uint8_t meter_value = 42U; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "R1", kBooleanType, read_boolean, &relay_state, false}, + mms::MmsStaticObjectEntry{ + "LD0", "M1", kInt32Type, read_small_int32, &meter_value, false}}; + const mms::MmsStaticObjectTable object_table{objects}; + const std::array event_members{ + mms::MmsStaticDataSetMember{"LD0", "R1"}, + mms::MmsStaticDataSetMember{"LD0", "M1"}}; + const std::array data_sets{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", event_members, false}}; + const mms::MmsStaticDataSetTable data_set_table{data_sets}; + if (!object_table.valid() || !data_set_table.valid() || + !data_set_table.valid_against(object_table)) { + return 7; + } + + const mms::MmsObjectNameView data_set_name{ + mms::MmsObjectNameViewKind::domain_specific, + kLd0, + kEvents}; + mms::MmsStaticInformationReportInput static_report; + static_report.report_id = "rpt"; + static_report.optional_fields = kOptionalFields; + static_report.sequence_number = 1U; + static_report.report_time = kReportTime; + static_report.buffered = false; + static_report.conf_revision = 1U; + static_report.reason_for_inclusion = 0x08U; + + std::array workspace{}; + const auto static_encoded = + mms::MmsStaticInformationReportEncoder::encode_data_set_snapshot_into( + object_table, + data_set_table, + data_set_name, + static_report, + buffer, + workspace); + if (!static_encoded.success() || static_encoded.member_count != 2U || + static_encoded.bytes_written != kExpectedReport.size() || + !matches( + std::span{buffer}.first(static_encoded.bytes_written), + kExpectedReport)) { + return 8; + } + + // The report has no shadow value store: changing the callback-owned source is + // visible in the next DataSet snapshot without rebuilding the DataSet model. + relay_state = false; + const auto changed = + mms::MmsStaticInformationReportEncoder::encode_data_set_snapshot_into( + object_table, + data_set_table, + data_set_name, + static_report, + buffer, + workspace); + if (!changed.success() || + !mms::MmsInformationReportSpanCodec::try_decode_information_report( + std::span{buffer}.first(changed.bytes_written), + decoded) || + !decoded.try_item(9U, item) || !item.success || + !matches(item.encoded_data, kBooleanFalse)) { + return 9; + } + + std::array tiny_workspace{}; + const auto workspace_shortage = + mms::MmsStaticInformationReportEncoder::encode_data_set_snapshot_into( + object_table, + data_set_table, + data_set_name, + static_report, + buffer, + tiny_workspace); + if (workspace_shortage.status != + mms::MmsStaticInformationReportStatus::workspace_too_small || + workspace_shortage.required_bytes != 3U) { + return 10; + } + + for (std::uint32_t iteration = 0U; iteration < 50'000U; ++iteration) { + report.sequence_number = iteration; + const auto repeated = mms::MmsInformationReportSpanCodec::encode_snapshot_into( + report, buffer); + mms::MmsInformationReportView repeated_decoded; + if (!repeated.success() || + !mms::MmsInformationReportSpanCodec::try_decode_information_report( + std::span{buffer}.first(repeated.bytes_written), + repeated_decoded) || + repeated_decoded.item_count != 13U) { + return 11; + } + } + + return 0; +} diff --git a/embedded/mms_pdu_hard_profile_smoke.cpp b/embedded/mms_pdu_hard_profile_smoke.cpp new file mode 100644 index 000000000..e5dc63a72 --- /dev/null +++ b/embedded/mms_pdu_hard_profile_smoke.cpp @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/pdu_span.hpp" +#include "ariec61850/osi/presentation_span.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kInitiateRequest{ + 0xA8U, 0x26U, + 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, + 0x81U, 0x01U, 0x0AU, + 0x82U, 0x01U, 0x0AU, + 0x83U, 0x01U, 0x05U, + 0xA4U, 0x16U, + 0x80U, 0x01U, 0x01U, + 0x81U, 0x03U, 0x05U, 0xF1U, 0x00U, + 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, + 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U}; + +constexpr std::array kInitiateResponse{ + 0xA9U, 0x26U, + 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, + 0x81U, 0x01U, 0x0AU, + 0x82U, 0x01U, 0x0AU, + 0x83U, 0x01U, 0x05U, + 0xA4U, 0x16U, + 0x80U, 0x01U, 0x01U, + 0x81U, 0x03U, 0x05U, 0xF1U, 0x00U, + 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, + 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U}; + +constexpr std::array kParameterSupport{ + 0x05U, 0xF1U, 0x00U}; +constexpr std::array kServicesSupported{ + 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, + 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U}; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +[[nodiscard]] bool valid_initiate( + const mms::MmsInitiateView& value, + const mms::MmsWirePduKind expected_kind) noexcept { + return value.kind == expected_kind && + value.maximum_mms_pdu_size == 65'000U && + value.maximum_outstanding_calling == 10U && + value.maximum_outstanding_called == 10U && + value.data_structure_nesting_level == 5U && + value.detail.version_number == 1U && + matches(value.detail.parameter_support_options, kParameterSupport) && + matches(value.detail.services_supported_calling, kServicesSupported); +} + +} // namespace + +int main() { + mms::MmsInitiateView initiate; + if (!mms::MmsPduSpanCodec::try_decode_initiate_request_view( + kInitiateRequest, initiate) || + !valid_initiate(initiate, mms::MmsWirePduKind::initiate_request)) { + return 1; + } + + if (!mms::MmsPduSpanCodec::try_decode_initiate_response_view( + kInitiateResponse, initiate) || + !valid_initiate(initiate, mms::MmsWirePduKind::initiate_response)) { + return 2; + } + + std::array buffer{}; + const auto initiate_result = + mms::MmsPduSpanCodec::encode_default_initiate_response_into(buffer); + if (!initiate_result.success() || + initiate_result.bytes_written != kInitiateResponse.size() || + !matches( + std::span{buffer}.first( + initiate_result.bytes_written), + kInitiateResponse)) { + return 3; + } + + constexpr std::array confirmed_request_golden{ + 0xA0U, 0x05U, 0x02U, 0x01U, 0x07U, 0xA4U, 0x00U}; + const auto confirmed_request_result = + mms::MmsPduSpanCodec::encode_confirmed_request_into( + 7U, 4, true, {}, buffer); + if (!confirmed_request_result.success() || + confirmed_request_result.bytes_written != confirmed_request_golden.size() || + !matches( + std::span{buffer}.first( + confirmed_request_result.bytes_written), + confirmed_request_golden)) { + return 4; + } + + mms::MmsConfirmedPduView confirmed; + if (!mms::MmsPduSpanCodec::try_decode_confirmed_request_view( + confirmed_request_golden, confirmed) || + confirmed.kind != mms::MmsWirePduKind::confirmed_request || + confirmed.invoke_id != 7U || confirmed.service_tag != 4 || + confirmed.service() != mms::MmsWireConfirmedService::read || + !confirmed.service_constructed || !confirmed.service_value.empty()) { + return 5; + } + + constexpr std::array confirmed_response_golden{ + 0xA1U, 0x05U, 0x02U, 0x01U, 0x07U, 0xA4U, 0x00U}; + const auto confirmed_response_result = + mms::MmsPduSpanCodec::encode_confirmed_response_into( + 7U, 4, true, {}, buffer); + if (!confirmed_response_result.success() || + confirmed_response_result.bytes_written != confirmed_response_golden.size() || + !matches( + std::span{buffer}.first( + confirmed_response_result.bytes_written), + confirmed_response_golden) || + !mms::MmsPduSpanCodec::try_decode_confirmed_response_view( + confirmed_response_golden, confirmed) || + confirmed.kind != mms::MmsWirePduKind::confirmed_response || + confirmed.invoke_id != 7U || confirmed.service_tag != 4) { + return 6; + } + + // Positive ASN.1 INTEGER boundary: max supported invoke ID has no sign + // prefix because its most-significant octet is 0x7F. + constexpr std::array maximum_invoke_golden{ + 0xA0U, 0x08U, 0x02U, 0x04U, + 0x7FU, 0xFFU, 0xFFU, 0xFFU, + 0xA4U, 0x00U}; + const auto maximum_result = mms::MmsPduSpanCodec::encode_confirmed_request_into( + mms::MmsPduSpanCodec::maximum_invoke_id, 4, true, {}, buffer); + if (!maximum_result.success() || + maximum_result.bytes_written != maximum_invoke_golden.size() || + !matches( + std::span{buffer}.first(maximum_result.bytes_written), + maximum_invoke_golden) || + !mms::MmsPduSpanCodec::try_decode_confirmed_request_view( + maximum_invoke_golden, confirmed) || + confirmed.invoke_id != mms::MmsPduSpanCodec::maximum_invoke_id) { + return 7; + } + + // Values beginning with bit 7 set require an explicit positive prefix. + constexpr std::array invoke_128_golden{ + 0xA0U, 0x06U, 0x02U, 0x02U, 0x00U, 0x80U, 0xA4U, 0x00U}; + const auto invoke_128 = mms::MmsPduSpanCodec::encode_confirmed_request_into( + 128U, 4, true, {}, buffer); + if (!invoke_128.success() || invoke_128.bytes_written != invoke_128_golden.size() || + !matches( + std::span{buffer}.first(invoke_128.bytes_written), + invoke_128_golden)) { + return 8; + } + + // Exercise the real post-association path: MMS Confirmed Request wrapped in + // Presentation P-DATA, then recovered as zero-copy spans. + std::array p_data{}; + const auto p_data_result = osi::PresentationSpanCodec::encode_p_data_into( + confirmed_request_golden, p_data, 3U, true); + osi::PresentationPdvView pdv; + if (!p_data_result.success() || + !osi::PresentationSpanCodec::try_decode_p_data_view( + std::span{p_data}.first(p_data_result.bytes_written), + pdv) || + pdv.context_id != 3U || + !mms::MmsPduSpanCodec::try_decode_confirmed_request_view( + pdv.single_asn1_type, confirmed) || + confirmed.invoke_id != 7U || + confirmed.service() != mms::MmsWireConfirmedService::read) { + return 9; + } + + // Repeated application-layer operation must stay inside caller-owned spans. + for (std::uint32_t iteration = 0U; iteration < 50'000U; ++iteration) { + const auto invoke = iteration & 0x7FFFU; + const auto encoded = mms::MmsPduSpanCodec::encode_confirmed_response_into( + invoke, 4, true, {}, buffer); + mms::MmsConfirmedPduView decoded; + if (!encoded.success() || + !mms::MmsPduSpanCodec::try_decode_confirmed_response_view( + std::span{buffer}.first(encoded.bytes_written), + decoded) || + decoded.invoke_id != invoke || decoded.service_tag != 4) { + return 10; + } + } + + constexpr std::array malformed_initiate{ + 0xA8U, 0x03U, 0x80U, 0x01U, 0x01U}; + if (mms::MmsPduSpanCodec::try_decode_initiate_request_view( + malformed_initiate, initiate)) { + return 11; + } + + return 0; +} diff --git a/embedded/mms_report_connection_hard_profile_smoke.cpp b/embedded/mms_report_connection_hard_profile_smoke.cpp new file mode 100644 index 000000000..e8941198a --- /dev/null +++ b/embedded/mms_report_connection_hard_profile_smoke.cpp @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/mms/information_report_span.hpp" +#include "ariec61850/mms/static_report_connection.hpp" +#include "ariec61850/mms/static_data_set_table.hpp" +#include "ariec61850/mms/static_object_table.hpp" +#include "ariec61850/osi/cotp_span.hpp" +#include "ariec61850/osi/presentation_span.hpp" +#include "ariec61850/osi/session_span.hpp" +#include "ariec61850/osi/tpkt_span.hpp" + +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kReportTime{ + 0x00U, 0x00U, 0x12U, 0x34U, 0x00U, 0x01U}; + +constexpr std::array kAssociationRequest{ + 0x0DU, 0xB6U, 0x05U, 0x06U, 0x13U, 0x01U, 0x00U, 0x16U, 0x01U, 0x02U, 0x14U, 0x02U, + 0x00U, 0x02U, 0x33U, 0x02U, 0x00U, 0x01U, 0x34U, 0x02U, 0x00U, 0x01U, 0xC1U, 0xA0U, + 0x31U, 0x81U, 0x9DU, 0xA0U, 0x03U, 0x80U, 0x01U, 0x01U, 0xA2U, 0x81U, 0x95U, 0x81U, + 0x04U, 0x00U, 0x00U, 0x00U, 0x01U, 0x82U, 0x04U, 0x00U, 0x00U, 0x00U, 0x01U, 0xA4U, + 0x23U, 0x30U, 0x0FU, 0x02U, 0x01U, 0x01U, 0x06U, 0x04U, 0x52U, 0x01U, 0x00U, 0x01U, + 0x30U, 0x04U, 0x06U, 0x02U, 0x51U, 0x01U, 0x30U, 0x10U, 0x02U, 0x01U, 0x03U, 0x06U, + 0x05U, 0x28U, 0xCAU, 0x22U, 0x02U, 0x01U, 0x30U, 0x04U, 0x06U, 0x02U, 0x51U, 0x01U, + 0x61U, 0x62U, 0x30U, 0x60U, 0x02U, 0x01U, 0x01U, 0xA0U, 0x5BU, 0x60U, 0x59U, 0xA1U, + 0x07U, 0x06U, 0x05U, 0x28U, 0xCAU, 0x22U, 0x02U, 0x03U, 0xA2U, 0x07U, 0x06U, 0x05U, + 0x29U, 0x01U, 0x87U, 0x67U, 0x01U, 0xA3U, 0x03U, 0x02U, 0x01U, 0x0CU, 0xA6U, 0x06U, + 0x06U, 0x04U, 0x29U, 0x01U, 0x87U, 0x67U, 0xA7U, 0x03U, 0x02U, 0x01U, 0x0CU, 0xBEU, + 0x33U, 0x28U, 0x31U, 0x06U, 0x02U, 0x51U, 0x01U, 0x02U, 0x01U, 0x03U, 0xA0U, 0x28U, + 0xA8U, 0x26U, 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, 0x81U, 0x01U, 0x0AU, 0x82U, 0x01U, + 0x0AU, 0x83U, 0x01U, 0x05U, 0xA4U, 0x16U, 0x80U, 0x01U, 0x01U, 0x81U, 0x03U, 0x05U, + 0xF1U, 0x00U, 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, 0x08U, 0x00U, + 0x00U, 0x79U, 0xEFU, 0x18U}; + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = *static_cast(context) ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +template +[[nodiscard]] wire::EncodeResult build_data_tpkt( + const std::span session_payload, + std::array& output, + std::array& scratch) noexcept { + const auto cotp = osi::CotpSpanCodec::encode_data_into(session_payload, scratch); + if (!cotp.success()) { + return cotp; + } + return osi::TpktSpanCodec::encode_into( + std::span{scratch}.first(cotp.bytes_written), output); +} + +[[nodiscard]] bool establish( + mms::MmsStaticConnectionRuntime& connection, + const std::span request, + const std::span response, + const std::span workspace, + const std::span scratch) noexcept { + constexpr std::array tpdu_size{0x0AU}; + constexpr std::array source_tsap{0x00U, 0x01U}; + constexpr std::array destination_tsap{0x00U, 0x01U}; + const std::array parameters{ + osi::CotpParameterView{osi::CotpSpanCodec::tpdu_size_parameter, tpdu_size}, + osi::CotpParameterView{osi::CotpSpanCodec::source_tsap_parameter, source_tsap}, + osi::CotpParameterView{osi::CotpSpanCodec::destination_tsap_parameter, destination_tsap}}; + + const auto cr = osi::CotpSpanCodec::encode_connection_request_into( + 0x0001U, parameters, scratch); + if (!cr.success()) { + return false; + } + const auto cr_tpkt = osi::TpktSpanCodec::encode_into( + std::span{scratch}.first(cr.bytes_written), request); + if (!cr_tpkt.success()) { + return false; + } + auto result = connection.process_tcp_window( + std::span{request.data(), cr_tpkt.bytes_written}, + response, + workspace); + if (!result.response_ready() || + connection.state() != mms::MmsStaticConnectionState::awaiting_association) { + return false; + } + + const auto cotp = osi::CotpSpanCodec::encode_data_into(kAssociationRequest, scratch); + if (!cotp.success()) { + return false; + } + const auto association = osi::TpktSpanCodec::encode_into( + std::span{scratch}.first(cotp.bytes_written), request); + if (!association.success()) { + return false; + } + result = connection.process_tcp_window( + std::span{request.data(), association.bytes_written}, + response, + workspace); + return result.response_ready() && + connection.state() == mms::MmsStaticConnectionState::established && + connection.mms_presentation_context_id() == 3U; +} + +[[nodiscard]] bool decode_report_frame( + const std::span frame, + mms::MmsInformationReportView& report) noexcept { + osi::TpktFrameView tpkt; + osi::CotpTpduView cotp; + osi::SessionDataTransferView session; + osi::PresentationPdvView pdv; + return osi::TpktSpanCodec::try_decode_view(frame, tpkt) && + osi::CotpSpanCodec::try_decode_view(tpkt.payload, cotp) && + cotp.kind == osi::CotpWireKind::data && cotp.end_of_transmission && + osi::SessionSpanCodec::try_decode_data_transfer_view(cotp.user_data, session) && + osi::PresentationSpanCodec::try_decode_fully_encoded_data_view( + session.presentation_payload, pdv) && + pdv.context_id == 3U && + mms::MmsInformationReportSpanCodec::try_decode_information_report( + pdv.single_asn1_type, report); +} + +[[nodiscard]] bool decode_unsigned_item( + const mms::MmsReadAccessResultView& item, + std::uint32_t& value) noexcept { + value = 0U; + if (!item.success) { + return false; + } + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(item.encoded_data, tlv) || + tlv.tag_class != asn1::BerClass::context_specific || + tlv.tag_number != 6 || tlv.constructed) { + return false; + } + const auto decoded = asn1::BerSpanReader::read_uint32(tlv); + if (!decoded) { + return false; + } + value = *decoded; + return true; +} + +} // namespace + +int main() { + bool relay_state = true; + bool alarm_state = false; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "R1", kBooleanType, read_boolean, &relay_state, false}, + mms::MmsStaticObjectEntry{ + "LD0", "A1", kBooleanType, read_boolean, &alarm_state, false}}; + const mms::MmsStaticObjectTable object_table{objects}; + const std::array members{ + mms::MmsStaticDataSetMember{"LD0", "R1"}, + mms::MmsStaticDataSetMember{"LD0", "A1"}}; + const std::array data_set_entries{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", members, false}}; + const mms::MmsStaticDataSetTable data_sets{data_set_entries}; + const mms::MmsStaticApplicationDispatcher dispatcher{object_table, data_sets}; + mms::MmsStaticConnectionRuntime connection{dispatcher}; + + const std::array definitions{ + mms::MmsStaticUrcbDefinition{ + "LD0", + "LLN0$RP$Events", + "LD0/LLN0$RP$Events", + "LD0", + "LLN0$Events", + 7U, + {0x7CU, 0x80U}, + 0U, + 0x08U, + 1'000U}}; + std::array states{}; + mms::MmsStaticUrcbRuntime reports{ + definitions, states, object_table, data_sets}; + if (!reports.initialize()) { + return 1; + } + + std::array request{}; + std::array response{}; + std::array workspace{}; + std::array scratch{}; + + auto poll = mms::MmsStaticReportConnection::poll( + connection, reports, 100U, kReportTime, response, workspace); + if (poll.status != mms::MmsStaticReportConnectionStatus::not_established) { + return 2; + } + if (!establish(connection, request, response, workspace, scratch)) { + return 3; + } + if (mms::MmsStaticReportConnection::poll( + connection, reports, 100U, kReportTime, response, workspace).status != + mms::MmsStaticReportConnectionStatus::no_report_due) { + return 4; + } + + if (reports.set_enabled(0U, true, 100U) != mms::MmsStaticUrcbStatus::ok || + reports.request_general_interrogation(0U) != mms::MmsStaticUrcbStatus::ok) { + return 5; + } + const auto* state = reports.state(0U); + if (state == nullptr || state->sequence_number != 0U || + !state->general_interrogation_pending) { + return 6; + } + + std::array tiny_response{}; + poll = mms::MmsStaticReportConnection::poll( + connection, reports, 100U, kReportTime, tiny_response, workspace); + state = reports.state(0U); + if (poll.status != mms::MmsStaticReportConnectionStatus::response_buffer_too_small || + poll.required_response_bytes <= tiny_response.size() || + poll.reason != mms::MmsStaticUrcbReportReason::general_interrogation || + poll.sequence_number != 1U || state == nullptr || state->sequence_number != 0U || + !state->general_interrogation_pending) { + return 7; + } + + poll = mms::MmsStaticReportConnection::poll( + connection, reports, 100U, kReportTime, response, workspace); + state = reports.state(0U); + if (!poll.response_ready() || poll.bytes_written == 0U || + poll.control_block_index != 0U || poll.sequence_number != 1U || + poll.reason != mms::MmsStaticUrcbReportReason::general_interrogation || + state == nullptr || state->sequence_number != 1U || + state->general_interrogation_pending) { + return 8; + } + + mms::MmsInformationReportView report; + if (!decode_report_frame( + std::span{response}.first(poll.bytes_written), report) || + report.item_count != 13U) { + return 9; + } + mms::MmsReadAccessResultView item; + std::uint32_t sequence = 0U; + if (!report.try_item(2U, item) || !decode_unsigned_item(item, sequence) || sequence != 1U) { + return 10; + } + + poll = mms::MmsStaticReportConnection::poll( + connection, reports, 100U, kReportTime, response, workspace); + if (poll.status != mms::MmsStaticReportConnectionStatus::no_report_due) { + return 11; + } + + // Integrity is due at t=1100. A workspace failure must preserve SqNum and due state. + std::array tiny_workspace{}; + poll = mms::MmsStaticReportConnection::poll( + connection, reports, 1'100U, kReportTime, response, tiny_workspace); + state = reports.state(0U); + if (poll.status != mms::MmsStaticReportConnectionStatus::workspace_too_small || + poll.required_workspace_bytes <= tiny_workspace.size() || + poll.reason != mms::MmsStaticUrcbReportReason::integrity || + poll.sequence_number != 2U || state == nullptr || state->sequence_number != 1U || + state->next_integrity_due_ms != 1'100U) { + return 12; + } + + poll = mms::MmsStaticReportConnection::poll( + connection, reports, 1'100U, kReportTime, response, workspace); + state = reports.state(0U); + if (!poll.response_ready() || poll.sequence_number != 2U || + poll.reason != mms::MmsStaticUrcbReportReason::integrity || + state == nullptr || state->sequence_number != 2U || + state->next_integrity_due_ms != 2'100U || + !decode_report_frame( + std::span{response}.first(poll.bytes_written), report) || + !report.try_item(2U, item) || !decode_unsigned_item(item, sequence) || sequence != 2U) { + return 13; + } + + connection.reset(); + poll = mms::MmsStaticReportConnection::poll( + connection, reports, 2'100U, kReportTime, response, workspace); + state = reports.state(0U); + if (poll.status != mms::MmsStaticReportConnectionStatus::not_established || + state == nullptr || state->sequence_number != 2U || + state->next_integrity_due_ms != 2'100U) { + return 14; + } + + return 0; +} diff --git a/embedded/mms_static_model_hard_profile_smoke.cpp b/embedded/mms_static_model_hard_profile_smoke.cpp new file mode 100644 index 000000000..f5ccac663 --- /dev/null +++ b/embedded/mms_static_model_hard_profile_smoke.cpp @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/services_span.hpp" +#include "ariec61850/mms/static_object_table.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kInt32Type{0x85U, 0x01U, 0x20U}; + +constexpr std::array kAttributesRequest{ + 0xA0U, 0x12U, 0x02U, 0x01U, 0x0BU, + 0xA6U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x52U, 0x31U}; + +constexpr std::array kAttributesResponse{ + 0xA1U, 0x0CU, 0x02U, 0x01U, 0x0BU, + 0xA6U, 0x07U, 0x80U, 0x01U, 0x00U, + 0xA2U, 0x02U, 0x83U, 0x00U}; + +constexpr std::array kReadRequest{ + 0xA0U, 0x27U, 0x02U, 0x01U, 0x0CU, + 0xA4U, 0x22U, 0xA1U, 0x20U, 0xA0U, 0x1EU, + 0x30U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x52U, 0x31U, + 0x30U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x4DU, 0x31U}; + +constexpr std::array kReadResponse{ + 0xA1U, 0x0DU, 0x02U, 0x01U, 0x0CU, + 0xA4U, 0x08U, 0xA1U, 0x06U, + 0x83U, 0x01U, 0xFFU, + 0x85U, 0x01U, 0x2AU}; + +constexpr std::array kLd0{0x4CU, 0x44U, 0x30U}; +constexpr std::array kR1{0x52U, 0x31U}; +constexpr std::array kM1{0x4DU, 0x31U}; +constexpr std::array kBooleanData{0x83U, 0x01U, 0xFFU}; +constexpr std::array kIntData{0x85U, 0x01U, 0x2AU}; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + const auto value = *static_cast(context); + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = value ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] wire::EncodeResult read_small_int32( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + const auto value = *static_cast(context); + if (value > 0x7FU) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x85U; + destination[1] = 0x01U; + destination[2] = value; + return {wire::EncodeStatus::ok, required, required}; +} + +} // namespace + +int main() { + const bool relay_state = true; + const std::uint8_t meter_value = 42U; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "R1", kBooleanType, read_boolean, &relay_state, false}, + mms::MmsStaticObjectEntry{ + "LD0", "M1", kInt32Type, read_small_int32, &meter_value, false}}; + const mms::MmsStaticObjectTable table{objects}; + if (!table.valid()) { + return 1; + } + + mms::MmsVariableAccessAttributesRequestView attributes_request; + if (!mms::MmsServiceSpanCodec::try_decode_variable_access_attributes_request( + kAttributesRequest, attributes_request) || + attributes_request.invoke_id != 11U || + attributes_request.name.kind != mms::MmsObjectNameViewKind::domain_specific || + !matches(attributes_request.name.domain, kLd0) || + !matches(attributes_request.name.item, kR1)) { + return 2; + } + const auto* relay = table.find(attributes_request.name); + if (relay == nullptr || relay->type_specification.data() != kBooleanType.data()) { + return 3; + } + + std::array buffer{}; + const auto attributes_encoded = + mms::MmsServiceSpanCodec::encode_variable_access_attributes_response_into( + attributes_request.invoke_id, + relay->mms_deletable, + relay->type_specification, + buffer); + if (!attributes_encoded.success() || + attributes_encoded.bytes_written != kAttributesResponse.size() || + !matches( + std::span{buffer}.first(attributes_encoded.bytes_written), + kAttributesResponse)) { + return 4; + } + + mms::MmsVariableAccessAttributesResponseView attributes_response; + if (!mms::MmsServiceSpanCodec::try_decode_variable_access_attributes_response( + kAttributesResponse, attributes_response) || + attributes_response.invoke_id != 11U || attributes_response.mms_deletable || + !matches(attributes_response.type_specification, kBooleanType)) { + return 5; + } + + mms::MmsReadRequestView read_request; + if (!mms::MmsServiceSpanCodec::try_decode_read_request(kReadRequest, read_request) || + read_request.invoke_id != 12U || read_request.specification_with_result || + read_request.variable_count != 2U) { + return 6; + } + + std::array resolved{}; + std::size_t resolved_count = 0U; + if (!table.try_resolve_read_request(read_request, resolved, resolved_count) || + resolved_count != 2U || resolved[0] != &objects[0] || resolved[1] != &objects[1]) { + return 7; + } + + mms::MmsObjectNameView variable; + if (!read_request.try_variable(0U, variable) || !matches(variable.item, kR1) || + !read_request.try_variable(1U, variable) || !matches(variable.item, kM1) || + read_request.try_variable(2U, variable)) { + return 8; + } + + std::array relay_data{}; + std::array meter_data{}; + const auto relay_read = resolved[0]->read(resolved[0]->context, relay_data); + const auto meter_read = resolved[1]->read(resolved[1]->context, meter_data); + if (!relay_read.success() || !meter_read.success() || + !matches( + std::span{relay_data}.first(relay_read.bytes_written), + kBooleanData) || + !matches( + std::span{meter_data}.first(meter_read.bytes_written), + kIntData)) { + return 9; + } + + const std::array results{ + mms::MmsReadAccessResultInput{ + true, + std::span{relay_data}.first(relay_read.bytes_written), + 0U}, + mms::MmsReadAccessResultInput{ + true, + std::span{meter_data}.first(meter_read.bytes_written), + 0U}}; + const auto read_encoded = mms::MmsServiceSpanCodec::encode_read_response_into( + read_request.invoke_id, results, buffer); + if (!read_encoded.success() || read_encoded.bytes_written != kReadResponse.size() || + !matches( + std::span{buffer}.first(read_encoded.bytes_written), + kReadResponse)) { + return 10; + } + + mms::MmsReadResponseView read_response; + if (!mms::MmsServiceSpanCodec::try_decode_read_response( + kReadResponse, read_response) || + read_response.invoke_id != 12U || read_response.result_count != 2U) { + return 11; + } + mms::MmsReadAccessResultView access; + if (!read_response.try_result(0U, access) || !access.success || + !matches(access.encoded_data, kBooleanData) || + !read_response.try_result(1U, access) || !access.success || + !matches(access.encoded_data, kIntData) || + read_response.try_result(2U, access)) { + return 12; + } + + // Failure results remain bounded and explicit; no exception or owning object + // graph is required to return an MMS DataAccessError. + const std::array failed{ + mms::MmsReadAccessResultInput{false, {}, 10U}}; + const auto failed_encoded = mms::MmsServiceSpanCodec::encode_read_response_into( + 13U, failed, buffer); + if (!failed_encoded.success() || + !mms::MmsServiceSpanCodec::try_decode_read_response( + std::span{buffer}.first(failed_encoded.bytes_written), + read_response) || + read_response.result_count != 1U || + !read_response.try_result(0U, access) || access.success || access.failure_code != 10U) { + return 13; + } + + for (std::uint32_t iteration = 0U; iteration < 50'000U; ++iteration) { + const auto invoke = iteration & 0x7FFFU; + const auto encoded = mms::MmsServiceSpanCodec::encode_read_response_into( + invoke, results, buffer); + mms::MmsReadResponseView decoded; + if (!encoded.success() || + !mms::MmsServiceSpanCodec::try_decode_read_response( + std::span{buffer}.first(encoded.bytes_written), + decoded) || + decoded.invoke_id != invoke || decoded.result_count != 2U) { + return 14; + } + } + + const std::array duplicates{ + objects[0], objects[0]}; + if (mms::MmsStaticObjectTable{duplicates}.valid()) { + return 15; + } + + return 0; +} diff --git a/embedded/mms_urcb_event_connection_hard_profile_smoke.cpp b/embedded/mms_urcb_event_connection_hard_profile_smoke.cpp new file mode 100644 index 000000000..38ab6919e --- /dev/null +++ b/embedded/mms_urcb_event_connection_hard_profile_smoke.cpp @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/mms/static_urcb_event_connection.hpp" +#include "ariec61850/mms/static_dispatcher.hpp" +#include "ariec61850/osi/cotp_span.hpp" +#include "ariec61850/osi/presentation_span.hpp" +#include "ariec61850/osi/session_span.hpp" +#include "ariec61850/osi/tpkt_span.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kReportTime{ + 0x00U, 0x00U, 0x12U, 0x34U, 0x00U, 0x01U}; + +constexpr std::array kAssociationRequest{ + 0x0DU,0xB6U,0x05U,0x06U,0x13U,0x01U,0x00U,0x16U,0x01U,0x02U,0x14U,0x02U, + 0x00U,0x02U,0x33U,0x02U,0x00U,0x01U,0x34U,0x02U,0x00U,0x01U,0xC1U,0xA0U, + 0x31U,0x81U,0x9DU,0xA0U,0x03U,0x80U,0x01U,0x01U,0xA2U,0x81U,0x95U,0x81U, + 0x04U,0x00U,0x00U,0x00U,0x01U,0x82U,0x04U,0x00U,0x00U,0x00U,0x01U,0xA4U, + 0x23U,0x30U,0x0FU,0x02U,0x01U,0x01U,0x06U,0x04U,0x52U,0x01U,0x00U,0x01U, + 0x30U,0x04U,0x06U,0x02U,0x51U,0x01U,0x30U,0x10U,0x02U,0x01U,0x03U,0x06U, + 0x05U,0x28U,0xCAU,0x22U,0x02U,0x01U,0x30U,0x04U,0x06U,0x02U,0x51U,0x01U, + 0x61U,0x62U,0x30U,0x60U,0x02U,0x01U,0x01U,0xA0U,0x5BU,0x60U,0x59U,0xA1U, + 0x07U,0x06U,0x05U,0x28U,0xCAU,0x22U,0x02U,0x03U,0xA2U,0x07U,0x06U,0x05U, + 0x29U,0x01U,0x87U,0x67U,0x01U,0xA3U,0x03U,0x02U,0x01U,0x0CU,0xA6U,0x06U, + 0x06U,0x04U,0x29U,0x01U,0x87U,0x67U,0xA7U,0x03U,0x02U,0x01U,0x0CU,0xBEU, + 0x33U,0x28U,0x31U,0x06U,0x02U,0x51U,0x01U,0x02U,0x01U,0x03U,0xA0U,0x28U, + 0xA8U,0x26U,0x80U,0x03U,0x00U,0xFDU,0xE8U,0x81U,0x01U,0x0AU,0x82U,0x01U, + 0x0AU,0x83U,0x01U,0x05U,0xA4U,0x16U,0x80U,0x01U,0x01U,0x81U,0x03U,0x05U, + 0xF1U,0x00U,0x82U,0x0CU,0x03U,0xEEU,0x1CU,0x00U,0x00U,0x04U,0x08U,0x00U, + 0x00U,0x79U,0xEFU,0x18U}; + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = *static_cast(context) ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] bool establish( + mms::MmsStaticConnectionRuntime& connection, + std::span request, + std::span response, + std::span workspace, + std::span scratch) noexcept { + constexpr std::array tpdu_size{0x0AU}; + constexpr std::array source_tsap{0x00U, 0x01U}; + constexpr std::array destination_tsap{0x00U, 0x01U}; + const std::array parameters{ + osi::CotpParameterView{osi::CotpSpanCodec::tpdu_size_parameter, tpdu_size}, + osi::CotpParameterView{osi::CotpSpanCodec::source_tsap_parameter, source_tsap}, + osi::CotpParameterView{osi::CotpSpanCodec::destination_tsap_parameter, destination_tsap}}; + + const auto cr = osi::CotpSpanCodec::encode_connection_request_into( + 0x0001U, parameters, scratch); + if (!cr.success()) return false; + const auto cr_tpkt = osi::TpktSpanCodec::encode_into( + scratch.first(cr.bytes_written), request); + if (!cr_tpkt.success()) return false; + auto result = connection.process_tcp_window( + request.first(cr_tpkt.bytes_written), response, workspace); + if (!result.response_ready() || + connection.state() != mms::MmsStaticConnectionState::awaiting_association) { + return false; + } + + const auto cotp = osi::CotpSpanCodec::encode_data_into(kAssociationRequest, scratch); + if (!cotp.success()) return false; + const auto association = osi::TpktSpanCodec::encode_into( + scratch.first(cotp.bytes_written), request); + if (!association.success()) return false; + result = connection.process_tcp_window( + request.first(association.bytes_written), response, workspace); + return result.response_ready() && + connection.state() == mms::MmsStaticConnectionState::established && + connection.mms_presentation_context_id() == 3U; +} + +[[nodiscard]] bool decode_report_frame( + const std::span frame, + mms::MmsInformationReportView& report) noexcept { + osi::TpktFrameView tpkt; + osi::CotpTpduView cotp; + osi::SessionDataTransferView session; + osi::PresentationPdvView pdv; + return osi::TpktSpanCodec::try_decode_view(frame, tpkt) && + osi::CotpSpanCodec::try_decode_view(tpkt.payload, cotp) && + cotp.kind == osi::CotpWireKind::data && cotp.end_of_transmission && + osi::SessionSpanCodec::try_decode_data_transfer_view(cotp.user_data, session) && + osi::PresentationSpanCodec::try_decode_fully_encoded_data_view( + session.presentation_payload, pdv) && + pdv.context_id == 3U && + mms::MmsInformationReportSpanCodec::try_decode_information_report( + pdv.single_asn1_type, report); +} + +[[nodiscard]] bool decode_bit_string( + const mms::MmsReadAccessResultView& result, + const std::uint8_t unused, + const std::uint8_t value) noexcept { + if (!result.success) return false; + asn1::BerTlvView tlv; + return asn1::BerSpanReader::try_read_exact(result.encoded_data, tlv) && + tlv.tag_class == asn1::BerClass::context_specific && + tlv.tag_number == 4 && !tlv.constructed && tlv.value.size() == 2U && + tlv.value[0] == unused && tlv.value[1] == value; +} + +} // namespace + +int main() { + bool first = true; + bool second = false; + bool third = true; + const std::array objects{ + mms::MmsStaticObjectEntry{"LD0","X1",kBooleanType,read_boolean,&first,false}, + mms::MmsStaticObjectEntry{"LD0","X2",kBooleanType,read_boolean,&second,false}, + mms::MmsStaticObjectEntry{"LD0","X3",kBooleanType,read_boolean,&third,false}}; + const mms::MmsStaticObjectTable object_table{objects}; + const std::array members{ + mms::MmsStaticDataSetMember{"LD0","X1"}, + mms::MmsStaticDataSetMember{"LD0","X2"}, + mms::MmsStaticDataSetMember{"LD0","X3"}}; + const std::array data_sets{ + mms::MmsStaticDataSetEntry{"LD0","LLN0$Events",members,false}}; + const mms::MmsStaticDataSetTable data_set_table{data_sets}; + const mms::MmsStaticApplicationDispatcher dispatcher{object_table,data_set_table}; + mms::MmsStaticConnectionRuntime connection{dispatcher}; + + const std::array definitions{ + mms::MmsStaticUrcbDefinition{ + "LD0","LLN0$RP$Events","LD0/LLN0$RP$Events", + "LD0","LLN0$Events",9U,{0x7CU,0x80U},20U,0x70U,0U}}; + std::array urcb_states{}; + mms::MmsStaticUrcbRuntime urcb{definitions,urcb_states,object_table,data_set_table}; + if (!urcb.initialize()) return 1; + std::array event_states{}; + mms::MmsStaticUrcbEventRuntime events{urcb,event_states,object_table,data_set_table}; + if (!events.initialize() || urcb.set_enabled(0U,true,100U) != mms::MmsStaticUrcbStatus::ok) { + return 2; + } + if (events.notify(0U,0U,mms::MmsStaticUrcbEventReason::data_change,100U) != + mms::MmsStaticUrcbEventStatus::ok || + events.notify(0U,2U,mms::MmsStaticUrcbEventReason::quality_change,105U) != + mms::MmsStaticUrcbEventStatus::ok) { + return 3; + } + + std::array request{}; + std::array response{}; + std::array workspace{}; + std::array scratch{}; + auto poll = mms::MmsStaticUrcbEventConnection::poll( + connection,events,120U,kReportTime,response,workspace); + if (poll.status != mms::MmsStaticUrcbEventConnectionStatus::not_established) return 4; + const auto* pending = events.state(0U); + if (pending == nullptr || !pending->pending || pending->pending_member_count != 2U) return 5; + + if (!establish(connection,request,response,workspace,scratch)) return 6; + + std::array tiny_response{}; + poll = mms::MmsStaticUrcbEventConnection::poll( + connection,events,120U,kReportTime,tiny_response,workspace); + pending = events.state(0U); + const auto* control = urcb.state(0U); + if (poll.status != mms::MmsStaticUrcbEventConnectionStatus::response_buffer_too_small || + poll.required_response_bytes <= tiny_response.size() || + poll.sequence_number != 1U || pending == nullptr || !pending->pending || + control == nullptr || control->sequence_number != 0U) { + return 7; + } + + std::array tiny_workspace{}; + poll = mms::MmsStaticUrcbEventConnection::poll( + connection,events,120U,kReportTime,response,tiny_workspace); + pending = events.state(0U); + control = urcb.state(0U); + if (poll.status != mms::MmsStaticUrcbEventConnectionStatus::workspace_too_small || + poll.required_workspace_bytes <= tiny_workspace.size() || + pending == nullptr || !pending->pending || control == nullptr || + control->sequence_number != 0U) { + return 8; + } + + poll = mms::MmsStaticUrcbEventConnection::poll( + connection,events,120U,kReportTime,response,workspace); + pending = events.state(0U); + control = urcb.state(0U); + if (!poll.response_ready() || poll.bytes_written == 0U || poll.sequence_number != 1U || + pending == nullptr || pending->pending || control == nullptr || + control->sequence_number != 1U) { + return 9; + } + + mms::MmsInformationReportView report; + if (!decode_report_frame( + std::span{response}.first(poll.bytes_written), report) || + report.item_count != 13U) { + return 10; + } + mms::MmsReadAccessResultView item; + if (!report.try_item(6U,item) || !decode_bit_string(item,5U,0xA0U) || + !report.try_item(11U,item) || !decode_bit_string(item,2U,0x80U) || + !report.try_item(12U,item) || !decode_bit_string(item,2U,0x40U)) { + return 11; + } + if (mms::MmsStaticUrcbEventConnection::poll( + connection,events,120U,kReportTime,response,workspace).status != + mms::MmsStaticUrcbEventConnectionStatus::no_report_due) { + return 12; + } + + connection.reset(); + if (events.notify(0U,1U,mms::MmsStaticUrcbEventReason::data_update,200U) != + mms::MmsStaticUrcbEventStatus::ok) { + return 13; + } + poll = mms::MmsStaticUrcbEventConnection::poll( + connection,events,220U,kReportTime,response,workspace); + pending = events.state(0U); + if (poll.status != mms::MmsStaticUrcbEventConnectionStatus::not_established || + pending == nullptr || !pending->pending || pending->pending_member_count != 1U) { + return 14; + } + + return 0; +} \ No newline at end of file diff --git a/embedded/mms_urcb_event_hard_profile_smoke.cpp b/embedded/mms_urcb_event_hard_profile_smoke.cpp new file mode 100644 index 000000000..8d13a3d5a --- /dev/null +++ b/embedded/mms_urcb_event_hard_profile_smoke.cpp @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/mms/static_urcb_event_runtime.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kReportTime{ + 0x00U, 0x00U, 0x12U, 0x34U, 0x00U, 0x01U}; + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = *static_cast(context) ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] bool decode_bit_string( + const mms::MmsReadAccessResultView& result, + const std::uint8_t expected_unused, + const std::uint8_t expected_value) noexcept { + if (!result.success) { + return false; + } + asn1::BerTlvView tlv; + return asn1::BerSpanReader::try_read_exact(result.encoded_data, tlv) && + tlv.tag_class == asn1::BerClass::context_specific && + tlv.tag_number == 4 && !tlv.constructed && tlv.value.size() == 2U && + tlv.value[0] == expected_unused && tlv.value[1] == expected_value; +} + +[[nodiscard]] bool decode_boolean( + const mms::MmsReadAccessResultView& result, + const bool expected) noexcept { + if (!result.success) { + return false; + } + asn1::BerTlvView tlv; + return asn1::BerSpanReader::try_read_exact(result.encoded_data, tlv) && + tlv.tag_class == asn1::BerClass::context_specific && + tlv.tag_number == 3 && !tlv.constructed && tlv.value.size() == 1U && + (tlv.value[0] != 0U) == expected; +} + +} // namespace + +int main() { + bool first = true; + bool second = false; + bool third = true; + const std::array objects{ + mms::MmsStaticObjectEntry{"LD0", "X1", kBooleanType, read_boolean, &first, false}, + mms::MmsStaticObjectEntry{"LD0", "X2", kBooleanType, read_boolean, &second, false}, + mms::MmsStaticObjectEntry{"LD0", "X3", kBooleanType, read_boolean, &third, false}}; + const mms::MmsStaticObjectTable object_table{objects}; + + const std::array members{ + mms::MmsStaticDataSetMember{"LD0", "X1"}, + mms::MmsStaticDataSetMember{"LD0", "X2"}, + mms::MmsStaticDataSetMember{"LD0", "X3"}}; + const std::array data_sets{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", members, false}}; + const mms::MmsStaticDataSetTable data_set_table{data_sets}; + + const std::array definitions{ + mms::MmsStaticUrcbDefinition{ + "LD0", + "LLN0$RP$Events", + "LD0/LLN0$RP$Events", + "LD0", + "LLN0$Events", + 9U, + {0x7CU, 0x80U}, + 20U, + 0x70U, + 0U}}; + std::array urcb_states{}; + mms::MmsStaticUrcbRuntime urcb{ + definitions, urcb_states, object_table, data_set_table}; + if (!urcb.initialize()) { + return 1; + } + + std::array event_states{}; + mms::MmsStaticUrcbEventRuntime events{ + urcb, event_states, object_table, data_set_table}; + if (!events.initialize() || !events.valid()) { + return 2; + } + if (events.notify(0U, 0U, mms::MmsStaticUrcbEventReason::data_change, 100U) != + mms::MmsStaticUrcbEventStatus::temporarily_unavailable) { + return 3; + } + if (urcb.set_enabled(0U, true, 100U) != mms::MmsStaticUrcbStatus::ok) { + return 4; + } + + if (events.notify(0U, 0U, mms::MmsStaticUrcbEventReason::data_change, 110U) != + mms::MmsStaticUrcbEventStatus::ok || + events.notify(0U, 0U, mms::MmsStaticUrcbEventReason::quality_change, 115U) != + mms::MmsStaticUrcbEventStatus::ok || + events.notify(0U, 2U, mms::MmsStaticUrcbEventReason::data_update, 119U) != + mms::MmsStaticUrcbEventStatus::ok) { + return 5; + } + const auto* pending = events.state(0U); + if (pending == nullptr || !pending->pending || pending->pending_member_count != 2U || + pending->due_ms != 130U || pending->member_reason_masks[0] != 0xC0U || + pending->member_reason_masks[1] != 0U || pending->member_reason_masks[2] != 0x20U) { + return 6; + } + + mms::MmsStaticUrcbEventPlan plan; + if (events.next_due(129U, plan) || !events.next_due(130U, plan) || + plan.index != 0U || plan.sequence_number != 1U) { + return 7; + } + + std::array tiny{}; + std::array output{}; + std::array workspace{}; + auto encoded = events.encode(plan, kReportTime, tiny, workspace); + if (encoded.status != mms::MmsStaticUrcbEventStatus::response_buffer_too_small || + encoded.required_bytes <= tiny.size()) { + return 8; + } + encoded = events.encode(plan, kReportTime, output, workspace); + if (!encoded.success() || encoded.data_set_member_count != 3U || + encoded.included_member_count != 2U) { + return 9; + } + + mms::MmsInformationReportView report; + if (!mms::MmsInformationReportSpanCodec::try_decode_information_report( + std::span{output}.first(encoded.bytes_written), report) || + report.item_count != 13U) { + return 10; + } + mms::MmsReadAccessResultView item; + // Full DataSet cardinality is three; only members 0 and 2 are included. + if (!report.try_item(6U, item) || !decode_bit_string(item, 5U, 0xA0U)) { + return 11; + } + if (!report.try_item(9U, item) || !decode_boolean(item, true) || + !report.try_item(10U, item) || !decode_boolean(item, true)) { + return 12; + } + // Member 0 coalesced dchg + qchg; member 2 is dupd. + if (!report.try_item(11U, item) || !decode_bit_string(item, 2U, 0xC0U) || + !report.try_item(12U, item) || !decode_bit_string(item, 2U, 0x20U)) { + return 13; + } + + if (events.commit(plan) != mms::MmsStaticUrcbEventStatus::ok || + events.commit(plan) != mms::MmsStaticUrcbEventStatus::stale_plan) { + return 14; + } + const auto* control = urcb.state(0U); + pending = events.state(0U); + if (control == nullptr || control->sequence_number != 1U || + pending == nullptr || pending->pending || pending->pending_member_count != 0U) { + return 15; + } + + // A new event after planning invalidates the old event revision. + if (events.notify(0U, 1U, mms::MmsStaticUrcbEventReason::data_change, 200U) != + mms::MmsStaticUrcbEventStatus::ok || + !events.next_due(220U, plan) || + events.notify(0U, 0U, mms::MmsStaticUrcbEventReason::quality_change, 220U) != + mms::MmsStaticUrcbEventStatus::ok || + events.encode(plan, kReportTime, output, workspace).status != + mms::MmsStaticUrcbEventStatus::stale_plan || + !events.next_due(220U, plan)) { + return 16; + } + encoded = events.encode(plan, kReportTime, output, workspace); + if (!encoded.success() || encoded.included_member_count != 2U || + events.commit(plan) != mms::MmsStaticUrcbEventStatus::ok) { + return 17; + } + + // Reconfiguration invalidates pending source revision rather than leaking + // a pre-disable event into a later enable interval. + if (events.notify(0U, 0U, mms::MmsStaticUrcbEventReason::data_change, 300U) != + mms::MmsStaticUrcbEventStatus::ok || + urcb.set_enabled(0U, false, 301U) != mms::MmsStaticUrcbStatus::ok || + urcb.set_enabled(0U, true, 302U) != mms::MmsStaticUrcbStatus::ok || + events.next_due(400U, plan) || + events.notify(0U, 2U, mms::MmsStaticUrcbEventReason::data_update, 400U) != + mms::MmsStaticUrcbEventStatus::ok || + !events.next_due(420U, plan)) { + return 18; + } + encoded = events.encode(plan, kReportTime, output, workspace); + if (!encoded.success() || encoded.included_member_count != 1U || + events.commit(plan) != mms::MmsStaticUrcbEventStatus::ok) { + return 19; + } + + // Secure trigger selection: a qchg event cannot bypass TrgOps. + if (urcb.set_enabled(0U, false, 500U) != mms::MmsStaticUrcbStatus::ok || + urcb.set_trigger_options(0U, 0x40U) != mms::MmsStaticUrcbStatus::ok || + urcb.set_buffer_time_ms(0U, 0U) != mms::MmsStaticUrcbStatus::ok || + urcb.set_enabled(0U, true, 500U) != mms::MmsStaticUrcbStatus::ok || + events.notify(0U, 0U, mms::MmsStaticUrcbEventReason::quality_change, 500U) != + mms::MmsStaticUrcbEventStatus::trigger_not_selected || + events.notify(0U, 0U, mms::MmsStaticUrcbEventReason::data_change, 500U) != + mms::MmsStaticUrcbEventStatus::ok || + !events.next_due(500U, plan)) { + return 20; + } + encoded = events.encode(plan, kReportTime, output, workspace); + if (!encoded.success() || events.commit(plan) != mms::MmsStaticUrcbEventStatus::ok) { + return 21; + } + + // Repeated immediate events prove deterministic bounded replay and 8-bit + // SqNum wrapping without heap-backed pending queues. + for (std::uint32_t iteration = 0U; iteration < 10'000U; ++iteration) { + if (events.notify(0U, 0U, mms::MmsStaticUrcbEventReason::data_change, 600U) != + mms::MmsStaticUrcbEventStatus::ok || + !events.next_due(600U, plan)) { + return 22; + } + encoded = events.encode(plan, kReportTime, output, workspace); + if (!encoded.success() || + events.commit(plan) != mms::MmsStaticUrcbEventStatus::ok) { + return 23; + } + } + + if (events.notify(0U, 3U, mms::MmsStaticUrcbEventReason::data_change, 700U) != + mms::MmsStaticUrcbEventStatus::member_out_of_range) { + return 24; + } + + return 0; +} diff --git a/embedded/mms_urcb_objects_hard_profile_smoke.cpp b/embedded/mms_urcb_objects_hard_profile_smoke.cpp new file mode 100644 index 000000000..45077e391 --- /dev/null +++ b/embedded/mms_urcb_objects_hard_profile_smoke.cpp @@ -0,0 +1,430 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/asn1/ber_span_writer.hpp" +#include "ariec61850/mms/static_dispatcher.hpp" +#include "ariec61850/mms/static_urcb_objects.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kTrueData{0x83U, 0x01U, 0xFFU}; +constexpr std::array kFalseData{0x83U, 0x01U, 0x00U}; +constexpr std::array kCustomReportId{ + 0x8AU, 0x08U, + 'C', 'U', 'S', 'T', 'O', 'M', '0', '1'}; + +[[nodiscard]] std::span as_bytes( + const std::string_view text) noexcept { + return { + reinterpret_cast(text.data()), + text.size()}; +} + +[[nodiscard]] mms::MmsObjectNameView object_name( + const std::string_view domain, + const std::string_view item) noexcept { + return { + mms::MmsObjectNameViewKind::domain_specific, + as_bytes(domain), + as_bytes(item)}; +} + +[[nodiscard]] std::uint64_t fake_now_ms(const void* context) noexcept { + return context == nullptr + ? std::uint64_t{0U} + : *static_cast(context); +} + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = *static_cast(context) ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] std::optional definition_size( + const std::string_view domain, + const std::string_view item) noexcept { + const auto domain_tlv = asn1::BerSpanWriter::tlv_size(26, domain.size()); + const auto item_tlv = asn1::BerSpanWriter::tlv_size(26, item.size()); + if (!domain_tlv || !item_tlv || + *item_tlv > std::numeric_limits::max() - *domain_tlv) { + return std::nullopt; + } + const auto object_content = *domain_tlv + *item_tlv; + const auto object = asn1::BerSpanWriter::tlv_size(1, object_content); + if (!object) { + return std::nullopt; + } + const auto name_wrapper = asn1::BerSpanWriter::tlv_size(0, *object); + if (!name_wrapper) { + return std::nullopt; + } + return asn1::BerSpanWriter::tlv_size(16, *name_wrapper); +} + +[[nodiscard]] bool write_definition( + asn1::BerSpanWriter& writer, + const std::string_view domain, + const std::string_view item) noexcept { + const auto domain_tlv = asn1::BerSpanWriter::tlv_size(26, domain.size()); + const auto item_tlv = asn1::BerSpanWriter::tlv_size(26, item.size()); + if (!domain_tlv || !item_tlv) { + return false; + } + const auto object_content = *domain_tlv + *item_tlv; + const auto object = asn1::BerSpanWriter::tlv_size(1, object_content); + const auto name_wrapper = object + ? asn1::BerSpanWriter::tlv_size(0, *object) + : std::optional{}; + if (!object || !name_wrapper) { + return false; + } + return writer.write_tlv_header( + asn1::BerClass::universal, true, 16, *name_wrapper) && + writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, *object) && + writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, object_content) && + writer.write_tlv( + asn1::BerClass::universal, false, 26, as_bytes(domain)) && + writer.write_tlv( + asn1::BerClass::universal, false, 26, as_bytes(item)); +} + +[[nodiscard]] wire::EncodeResult encode_single_read_request( + const std::uint32_t invoke_id, + const std::string_view domain, + const std::string_view item, + const std::span destination) noexcept { + const auto definition = definition_size(domain, item); + const auto list = definition + ? asn1::BerSpanWriter::tlv_size(0, *definition) + : std::optional{}; + const auto specification = list + ? asn1::BerSpanWriter::tlv_size(1, *list) + : std::optional{}; + if (!definition || !list || !specification || *specification > 256U) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + std::array service{}; + asn1::BerSpanWriter writer{service}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, *list) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, *definition) || + !write_definition(writer, domain, item) || writer.size() != *specification) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + return mms::MmsPduSpanCodec::encode_confirmed_request_into( + invoke_id, + static_cast(mms::MmsWireConfirmedService::read), + true, + std::span{service}.first(writer.size()), + destination); +} + +[[nodiscard]] wire::EncodeResult encode_single_write_request( + const std::uint32_t invoke_id, + const std::string_view domain, + const std::string_view item, + const std::span encoded_data, + const std::span destination) noexcept { + const auto definition = definition_size(domain, item); + const auto list = definition + ? asn1::BerSpanWriter::tlv_size(0, *definition) + : std::optional{}; + const auto data_list = asn1::BerSpanWriter::tlv_size(0, encoded_data.size()); + if (!definition || !list || !data_list || + *data_list > std::numeric_limits::max() - *list || + *list + *data_list > 256U) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + std::array service{}; + asn1::BerSpanWriter writer{service}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, *definition) || + !write_definition(writer, domain, item) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, encoded_data.size()) || + !writer.write_bytes(encoded_data) || writer.size() != *list + *data_list) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + return mms::MmsPduSpanCodec::encode_confirmed_request_into( + invoke_id, + static_cast(mms::MmsWireConfirmedService::write), + true, + std::span{service}.first(writer.size()), + destination); +} + +} // namespace + +int main() { + bool relay_state = true; + bool alarm_state = false; + const std::array base_objects{ + mms::MmsStaticObjectEntry{ + "LD0", "R1", kBooleanType, read_boolean, &relay_state, false}, + mms::MmsStaticObjectEntry{ + "LD0", "A1", kBooleanType, read_boolean, &alarm_state, false}}; + const mms::MmsStaticObjectTable base_table{base_objects}; + + const std::array members{ + mms::MmsStaticDataSetMember{"LD0", "R1"}, + mms::MmsStaticDataSetMember{"LD0", "A1"}}; + const std::array data_set_entries{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", members, false}}; + const mms::MmsStaticDataSetTable data_sets{data_set_entries}; + + const std::array definitions{ + mms::MmsStaticUrcbDefinition{ + "LD0", + "LLN0$RP$Events", + "LD0/LLN0$RP$Events", + "LD0", + "LLN0$Events", + 7U, + {0x7CU, 0x80U}, + 0U, + 0x08U, + 1'000U}}; + std::array states{}; + mms::MmsStaticUrcbRuntime runtime{ + definitions, states, base_table, data_sets}; + if (!runtime.initialize()) { + return 1; + } + + std::uint64_t now_ms = 100U; + std::array object_storage{}; + std::array context_storage{}; + std::array name_storage{}; + mms::MmsStaticUrcbObjectBank bank{ + runtime, + base_objects, + object_storage, + context_storage, + name_storage, + fake_now_ms, + &now_ms}; + if (bank.required_object_capacity() != object_storage.size() || + bank.required_context_capacity() != context_storage.size() || + bank.required_name_bytes() == 0U || + bank.required_name_bytes() > name_storage.size() || + !bank.initialize() || !bank.valid() || + bank.object_count() != object_storage.size() || !bank.table().valid()) { + return 2; + } + + const auto rpt_ena_name = object_name("LD0", "LLN0$RP$Events$RptEna"); + const auto rpt_id_name = object_name("LD0", "LLN0$RP$Events$RptID"); + const auto gi_name = object_name("LD0", "LLN0$RP$Events$GI"); + const auto sq_num_name = object_name("LD0", "LLN0$RP$Events$SqNum"); + const auto* rpt_ena = bank.table().find(rpt_ena_name); + const auto* rpt_id = bank.table().find(rpt_id_name); + const auto* gi = bank.table().find(gi_name); + const auto* sq_num = bank.table().find(sq_num_name); + if (rpt_ena == nullptr || rpt_id == nullptr || gi == nullptr || sq_num == nullptr || + !rpt_ena->writable() || !rpt_id->writable() || !gi->writable() || + sq_num->writable()) { + return 3; + } + + std::array read_buffer{}; + auto read = rpt_ena->read(rpt_ena->context, read_buffer); + if (!read.success() || read.bytes_written != kFalseData.size() || + !std::equal( + read_buffer.begin(), + read_buffer.begin() + static_cast(read.bytes_written), + kFalseData.begin())) { + return 4; + } + std::array tiny{}; + read = rpt_ena->read(rpt_ena->context, tiny); + if (read.status != wire::EncodeStatus::buffer_too_small || read.required_bytes != 3U) { + return 5; + } + + const auto report_id_write = rpt_id->write(rpt_id->write_context, kCustomReportId); + const auto* state = runtime.state(0U); + if (!report_id_write.success || state == nullptr || state->report_id() != "CUSTOM01") { + return 6; + } + + mms::MmsStaticApplicationDispatcher dispatcher{bank.table(), data_sets}; + std::array request{}; + std::array response{}; + std::array workspace{}; + + const auto enable_request = encode_single_write_request( + 21U, + "LD0", + "LLN0$RP$Events$RptEna", + kTrueData, + request); + if (!enable_request.success()) { + return 7; + } + auto dispatched = dispatcher.dispatch( + std::span{request}.first(enable_request.bytes_written), + response, + workspace); + mms::MmsWriteResponseView write_response; + mms::MmsWriteAccessResultView write_result; + state = runtime.state(0U); + if (!dispatched.success() || dispatched.service != mms::MmsWireConfirmedService::write || + !mms::MmsServiceSpanCodec::try_decode_write_response( + std::span{response}.first(dispatched.bytes_written), + write_response) || + !write_response.try_result(0U, write_result) || !write_result.success || + state == nullptr || !state->enabled || !state->integrity_armed || + state->next_integrity_due_ms != 1'100U) { + return 8; + } + + const auto read_request = encode_single_read_request( + 22U, + "LD0", + "LLN0$RP$Events$RptEna", + request); + if (!read_request.success()) { + return 9; + } + dispatched = dispatcher.dispatch( + std::span{request}.first(read_request.bytes_written), + response, + workspace); + mms::MmsReadResponseView read_response; + mms::MmsReadAccessResultView access; + if (!dispatched.success() || + !mms::MmsServiceSpanCodec::try_decode_read_response( + std::span{response}.first(dispatched.bytes_written), + read_response) || + !read_response.try_result(0U, access) || !access.success || + access.encoded_data.size() != kTrueData.size() || + !std::equal(access.encoded_data.begin(), access.encoded_data.end(), kTrueData.begin())) { + return 10; + } + + const auto denied_request = encode_single_write_request( + 23U, + "LD0", + "LLN0$RP$Events$RptID", + kCustomReportId, + request); + if (!denied_request.success()) { + return 11; + } + dispatched = dispatcher.dispatch( + std::span{request}.first(denied_request.bytes_written), + response, + workspace); + if (!dispatched.success() || + !mms::MmsServiceSpanCodec::try_decode_write_response( + std::span{response}.first(dispatched.bytes_written), + write_response) || + !write_response.try_result(0U, write_result) || write_result.success || + write_result.failure_code != 3U) { + return 12; + } + + const auto gi_request = encode_single_write_request( + 24U, + "LD0", + "LLN0$RP$Events$GI", + kTrueData, + request); + if (!gi_request.success()) { + return 13; + } + dispatched = dispatcher.dispatch( + std::span{request}.first(gi_request.bytes_written), + response, + workspace); + state = runtime.state(0U); + if (!dispatched.success() || + !mms::MmsServiceSpanCodec::try_decode_write_response( + std::span{response}.first(dispatched.bytes_written), + write_response) || + !write_response.try_result(0U, write_result) || !write_result.success || + state == nullptr || !state->general_interrogation_pending) { + return 14; + } + + mms::MmsStaticUrcbEmissionPlan plan; + if (!runtime.next_due(now_ms, plan) || + plan.reason != mms::MmsStaticUrcbReportReason::general_interrogation || + plan.sequence_number != 1U) { + return 15; + } + + now_ms = 200U; + const auto disable_request = encode_single_write_request( + 25U, + "LD0", + "LLN0$RP$Events$RptEna", + kFalseData, + request); + if (!disable_request.success()) { + return 16; + } + dispatched = dispatcher.dispatch( + std::span{request}.first(disable_request.bytes_written), + response, + workspace); + state = runtime.state(0U); + if (!dispatched.success() || state == nullptr || state->enabled || + state->general_interrogation_pending || state->integrity_armed) { + return 17; + } + + const auto gi_while_disabled = gi->write(gi->write_context, kTrueData); + if (gi_while_disabled.success || gi_while_disabled.failure_code != 2U) { + return 18; + } + + read = sq_num->read(sq_num->context, read_buffer); + if (!read.success() || read.bytes_written != 3U || + read_buffer[0] != 0x86U || read_buffer[1] != 0x01U || read_buffer[2] != 0x00U) { + return 19; + } + + // Storage shortages fail closed without publishing a partial object table. + std::array short_objects{}; + mms::MmsStaticUrcbObjectBank short_bank{ + runtime, + base_objects, + short_objects, + context_storage, + name_storage, + fake_now_ms, + &now_ms}; + if (short_bank.initialize() || short_bank.valid()) { + return 20; + } + + return 0; +} diff --git a/embedded/mms_urcb_runtime_hard_profile_smoke.cpp b/embedded/mms_urcb_runtime_hard_profile_smoke.cpp new file mode 100644 index 000000000..425ab239f --- /dev/null +++ b/embedded/mms_urcb_runtime_hard_profile_smoke.cpp @@ -0,0 +1,317 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/mms/static_urcb_runtime.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kReportTime{ + 0x00U, 0x00U, 0x12U, 0x34U, 0x00U, 0x01U}; + +[[nodiscard]] std::span as_bytes( + const std::string_view text) noexcept { + return { + reinterpret_cast(text.data()), + text.size()}; +} + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = *static_cast(context) ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] bool decode_unsigned( + const mms::MmsReadAccessResultView& result, + std::uint32_t& value) noexcept { + value = 0U; + if (!result.success) { + return false; + } + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(result.encoded_data, tlv) || + tlv.tag_class != asn1::BerClass::context_specific || + tlv.tag_number != 6 || tlv.constructed) { + return false; + } + const auto decoded = asn1::BerSpanReader::read_uint32(tlv); + if (!decoded) { + return false; + } + value = *decoded; + return true; +} + +[[nodiscard]] bool decode_boolean( + const mms::MmsReadAccessResultView& result, + bool& value) noexcept { + value = false; + if (!result.success) { + return false; + } + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(result.encoded_data, tlv) || + tlv.tag_class != asn1::BerClass::context_specific || + tlv.tag_number != 3 || tlv.constructed || tlv.value.size() != 1U) { + return false; + } + value = tlv.value[0] != 0U; + return true; +} + +} // namespace + +int main() { + bool relay_state = true; + bool alarm_state = false; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "R1", kBooleanType, read_boolean, &relay_state, false}, + mms::MmsStaticObjectEntry{ + "LD0", "A1", kBooleanType, read_boolean, &alarm_state, false}}; + const mms::MmsStaticObjectTable object_table{objects}; + + const std::array members{ + mms::MmsStaticDataSetMember{"LD0", "R1"}, + mms::MmsStaticDataSetMember{"LD0", "A1"}}; + const std::array data_sets{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", members, false}}; + const mms::MmsStaticDataSetTable data_set_table{data_sets}; + + const std::array definitions{ + mms::MmsStaticUrcbDefinition{ + "LD0", + "LLN0$RP$Events", + "LD0/LLN0$RP$Events", + "LD0", + "LLN0$Events", + 7U, + {0x7CU, 0x80U}, + 0U, + 0x0CU, + 1'000U}}; + std::array states{}; + mms::MmsStaticUrcbRuntime runtime{ + definitions, states, object_table, data_set_table}; + if (!runtime.initialize() || !runtime.valid() || runtime.size() != 1U) { + return 1; + } + + const auto* initial = runtime.state(0U); + if (initial == nullptr || initial->enabled || initial->sequence_number != 0U || + initial->report_id() != "LD0/LLN0$RP$Events" || + initial->data_set_domain() != "LD0" || + initial->data_set_item() != "LLN0$Events") { + return 2; + } + + std::size_t found = 99U; + const mms::MmsObjectNameView rcb_name{ + mms::MmsObjectNameViewKind::domain_specific, + as_bytes("LD0"), + as_bytes("LLN0$RP$Events")}; + if (!runtime.find_index(rcb_name, found) || found != 0U) { + return 3; + } + + mms::MmsStaticUrcbEmissionPlan plan; + if (runtime.next_due(0U, plan) || + runtime.request_general_interrogation(0U) != + mms::MmsStaticUrcbStatus::temporarily_unavailable) { + return 4; + } + + if (runtime.set_enabled(0U, true, 100U) != mms::MmsStaticUrcbStatus::ok) { + return 5; + } + const auto* enabled = runtime.state(0U); + if (enabled == nullptr || !enabled->enabled || !enabled->integrity_armed || + enabled->next_integrity_due_ms != 1'100U || enabled->sequence_number != 0U) { + return 6; + } + if (runtime.set_report_id(0U, "blocked") != + mms::MmsStaticUrcbStatus::object_access_denied || + runtime.set_data_set(0U, "LD0", "LLN0$Events") != + mms::MmsStaticUrcbStatus::object_access_denied || + runtime.set_integrity_period_ms(0U, 500U) != + mms::MmsStaticUrcbStatus::object_access_denied) { + return 7; + } + + if (runtime.request_general_interrogation(0U) != mms::MmsStaticUrcbStatus::ok || + !runtime.next_due(100U, plan) || plan.index != 0U || + plan.reason != mms::MmsStaticUrcbReportReason::general_interrogation || + plan.sequence_number != 1U) { + return 8; + } + + std::array tiny{}; + std::array output{}; + std::array workspace{}; + auto encoded = runtime.encode(plan, kReportTime, tiny, workspace); + if (encoded.status != mms::MmsStaticUrcbStatus::response_buffer_too_small || + encoded.required_bytes <= tiny.size()) { + return 9; + } + const auto* before_commit = runtime.state(0U); + if (before_commit == nullptr || before_commit->sequence_number != 0U || + !before_commit->general_interrogation_pending) { + return 10; + } + + encoded = runtime.encode(plan, kReportTime, output, workspace); + if (!encoded.success() || encoded.member_count != 2U) { + return 11; + } + mms::MmsInformationReportView report; + if (!mms::MmsInformationReportSpanCodec::try_decode_information_report( + std::span{output}.first(encoded.bytes_written), report) || + report.item_count != 13U) { + return 12; + } + mms::MmsReadAccessResultView item; + std::uint32_t sequence = 0U; + if (!report.try_item(2U, item) || !decode_unsigned(item, sequence) || sequence != 1U) { + return 13; + } + bool relay_value = false; + bool alarm_value = true; + if (!report.try_item(9U, item) || !decode_boolean(item, relay_value) || !relay_value || + !report.try_item(10U, item) || !decode_boolean(item, alarm_value) || alarm_value) { + return 14; + } + + if (runtime.commit(plan, 100U) != mms::MmsStaticUrcbStatus::ok) { + return 15; + } + const auto* after_gi = runtime.state(0U); + if (after_gi == nullptr || after_gi->sequence_number != 1U || + after_gi->general_interrogation_pending || + after_gi->next_integrity_due_ms != 1'100U || + runtime.commit(plan, 100U) != mms::MmsStaticUrcbStatus::stale_plan) { + return 16; + } + + if (runtime.next_due(1'099U, plan) || !runtime.next_due(1'100U, plan) || + plan.reason != mms::MmsStaticUrcbReportReason::integrity || + plan.sequence_number != 2U) { + return 17; + } + encoded = runtime.encode(plan, kReportTime, output, workspace); + if (!encoded.success() || runtime.commit(plan, 1'100U) != mms::MmsStaticUrcbStatus::ok) { + return 18; + } + const auto* after_integrity = runtime.state(0U); + if (after_integrity == nullptr || after_integrity->sequence_number != 2U || + after_integrity->next_integrity_due_ms != 2'100U) { + return 19; + } + + if (!runtime.next_due(5'500U, plan) || + plan.reason != mms::MmsStaticUrcbReportReason::integrity || + runtime.commit(plan, 5'500U) != mms::MmsStaticUrcbStatus::ok) { + return 20; + } + const auto* no_catch_up = runtime.state(0U); + if (no_catch_up == nullptr || no_catch_up->sequence_number != 3U || + no_catch_up->next_integrity_due_ms != 6'500U) { + return 21; + } + + if (runtime.request_general_interrogation(0U) != mms::MmsStaticUrcbStatus::ok || + !runtime.next_due(5'500U, plan) || + runtime.set_reserved(0U, true) != mms::MmsStaticUrcbStatus::ok) { + return 22; + } + encoded = runtime.encode(plan, kReportTime, output, workspace); + if (encoded.status != mms::MmsStaticUrcbStatus::stale_plan || + !runtime.next_due(5'500U, plan) || + plan.reason != mms::MmsStaticUrcbReportReason::general_interrogation) { + return 23; + } + encoded = runtime.encode(plan, kReportTime, output, workspace); + if (!encoded.success() || runtime.commit(plan, 5'500U) != mms::MmsStaticUrcbStatus::ok) { + return 24; + } + + if (runtime.set_enabled(0U, false, 6'000U) != mms::MmsStaticUrcbStatus::ok || + runtime.request_general_interrogation(0U) != + mms::MmsStaticUrcbStatus::temporarily_unavailable) { + return 25; + } + const std::array invalid_optional{0x7CU, 0x40U}; + const std::array compact_optional{0x5CU, 0x80U}; + if (runtime.set_report_id(0U, "LD0/LLN0$RP$Runtime") != + mms::MmsStaticUrcbStatus::ok || + runtime.set_data_set(0U, "LD0", "Missing") != + mms::MmsStaticUrcbStatus::data_set_not_found || + runtime.set_optional_fields(0U, invalid_optional) != + mms::MmsStaticUrcbStatus::invalid_value || + runtime.set_optional_fields(0U, compact_optional) != + mms::MmsStaticUrcbStatus::ok || + runtime.set_trigger_options(0U, 0x08U) != mms::MmsStaticUrcbStatus::ok || + runtime.set_integrity_period_ms(0U, 50U) != mms::MmsStaticUrcbStatus::ok || + runtime.set_buffer_time_ms(0U, 10U) != mms::MmsStaticUrcbStatus::ok) { + return 26; + } + + if (runtime.set_enabled(0U, true, 7'000U) != mms::MmsStaticUrcbStatus::ok) { + return 27; + } + const auto* clamped = runtime.state(0U); + if (clamped == nullptr || clamped->sequence_number != 0U || + clamped->next_integrity_due_ms != 7'100U || + runtime.next_due(7'099U, plan) || !runtime.next_due(7'100U, plan)) { + return 28; + } + + // Keep time below the integrity deadline and exercise repeated GI generation. + // The two-phase plan must preserve every report across buffer retries and wrap SqNum at 8 bits. + for (std::uint32_t iteration = 0U; iteration < 50'000U; ++iteration) { + if (runtime.request_general_interrogation(0U) != mms::MmsStaticUrcbStatus::ok || + !runtime.next_due(7'000U, plan) || + plan.reason != mms::MmsStaticUrcbReportReason::general_interrogation) { + return 29; + } + encoded = runtime.encode(plan, kReportTime, output, workspace); + if (!encoded.success() || + runtime.commit(plan, 7'000U) != mms::MmsStaticUrcbStatus::ok) { + return 30; + } + } + const auto* wrapped = runtime.state(0U); + if (wrapped == nullptr || wrapped->sequence_number != 80U || + wrapped->next_integrity_due_ms != 7'100U) { + return 31; + } + + const std::array duplicates{ + definitions[0], definitions[0]}; + std::array duplicate_states{}; + mms::MmsStaticUrcbRuntime duplicate_runtime{ + duplicates, duplicate_states, object_table, data_set_table}; + if (duplicate_runtime.initialize()) { + return 32; + } + + return 0; +} diff --git a/embedded/mms_write_hard_profile_smoke.cpp b/embedded/mms_write_hard_profile_smoke.cpp new file mode 100644 index 000000000..7be9e886a --- /dev/null +++ b/embedded/mms_write_hard_profile_smoke.cpp @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/services_span.hpp" +#include "ariec61850/mms/static_object_table.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kInt32Type{0x85U, 0x01U, 0x20U}; + +constexpr std::array kWriteRequest{ + 0xA0U, 0x1BU, 0x02U, 0x01U, 0x0EU, + 0xA5U, 0x16U, + 0xA0U, 0x0FU, + 0x30U, 0x0DU, 0xA0U, 0x0BU, 0xA1U, 0x09U, + 0x1AU, 0x03U, 0x4CU, 0x44U, 0x30U, + 0x1AU, 0x02U, 0x52U, 0x31U, + 0xA0U, 0x03U, 0x83U, 0x01U, 0x00U}; + +constexpr std::array kWriteSuccessResponse{ + 0xA1U, 0x07U, 0x02U, 0x01U, 0x0EU, + 0xA5U, 0x02U, 0x81U, 0x00U}; + +constexpr std::array kWriteFailureResponse{ + 0xA1U, 0x08U, 0x02U, 0x01U, 0x0FU, + 0xA5U, 0x03U, 0x80U, 0x01U, 0x03U}; + +constexpr std::array kLd0{0x4CU, 0x44U, 0x30U}; +constexpr std::array kR1{0x52U, 0x31U}; +constexpr std::array kM1{0x4DU, 0x31U}; +constexpr std::array kFalseData{0x83U, 0x01U, 0x00U}; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + const auto value = *static_cast(context); + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = value ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] wire::EncodeResult read_small_int32( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + const auto value = *static_cast(context); + if (value > 0x7FU) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x85U; + destination[1] = 0x01U; + destination[2] = value; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] mms::MmsStaticWriteResult write_boolean( + void* context, + const std::span encoded_data) noexcept { + if (context == nullptr || encoded_data.size() != 3U || + encoded_data[0] != 0x83U || encoded_data[1] != 0x01U || + (encoded_data[2] != 0x00U && encoded_data[2] != 0xFFU)) { + return {false, 7U}; + } + *static_cast(context) = encoded_data[2] != 0x00U; + return {true, 0U}; +} + +} // namespace + +int main() { + bool relay_state = true; + const std::uint8_t meter_value = 42U; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "R1", kBooleanType, + read_boolean, &relay_state, false, + write_boolean, &relay_state}, + mms::MmsStaticObjectEntry{ + "LD0", "M1", kInt32Type, + read_small_int32, &meter_value, false}}; + const mms::MmsStaticObjectTable table{objects}; + if (!table.valid() || !objects[0].writable() || objects[1].writable()) { + return 1; + } + + mms::MmsWriteRequestView request; + if (!mms::MmsServiceSpanCodec::try_decode_write_request(kWriteRequest, request) || + request.invoke_id != 14U || request.variable_count != 1U || request.data_count != 1U) { + return 2; + } + mms::MmsObjectNameView name; + std::span value; + if (!request.try_variable(0U, name) || + name.kind != mms::MmsObjectNameViewKind::domain_specific || + !matches(name.domain, kLd0) || !matches(name.item, kR1) || + !request.try_value(0U, value) || !matches(value, kFalseData) || + request.try_variable(1U, name) || request.try_value(1U, value)) { + return 3; + } + + std::array resolved{}; + std::size_t resolved_count = 0U; + if (!table.try_resolve_write_request(request, resolved, resolved_count) || + resolved_count != 1U || resolved[0] != &objects[0] || !resolved[0]->writable()) { + return 4; + } + if (!request.try_value(0U, value)) { + return 5; + } + const auto write_result = resolved[0]->write(resolved[0]->write_context, value); + if (!write_result.success || relay_state) { + return 6; + } + + std::array buffer{}; + const std::array success{ + mms::MmsWriteAccessResultInput{true, 0U}}; + const auto success_encoded = mms::MmsServiceSpanCodec::encode_write_response_into( + request.invoke_id, success, buffer); + if (!success_encoded.success() || + success_encoded.bytes_written != kWriteSuccessResponse.size() || + !matches( + std::span{buffer}.first(success_encoded.bytes_written), + kWriteSuccessResponse)) { + return 7; + } + + mms::MmsWriteResponseView response; + mms::MmsWriteAccessResultView access; + if (!mms::MmsServiceSpanCodec::try_decode_write_response( + kWriteSuccessResponse, response) || + response.invoke_id != 14U || response.result_count != 1U || + !response.try_result(0U, access) || !access.success || + response.try_result(1U, access)) { + return 8; + } + + auto read_only_request = kWriteRequest; + read_only_request[4] = 0x0FU; + read_only_request[22] = 0x4DU; + if (!mms::MmsServiceSpanCodec::try_decode_write_request( + read_only_request, request) || + !request.try_variable(0U, name) || !matches(name.item, kM1) || + !table.try_resolve_write_request(request, resolved, resolved_count) || + resolved_count != 1U || resolved[0] != &objects[1] || resolved[0]->writable()) { + return 9; + } + + const std::array denied{ + mms::MmsWriteAccessResultInput{false, 3U}}; + const auto denied_encoded = mms::MmsServiceSpanCodec::encode_write_response_into( + request.invoke_id, denied, buffer); + if (!denied_encoded.success() || + denied_encoded.bytes_written != kWriteFailureResponse.size() || + !matches( + std::span{buffer}.first(denied_encoded.bytes_written), + kWriteFailureResponse) || + !mms::MmsServiceSpanCodec::try_decode_write_response( + kWriteFailureResponse, response) || + !response.try_result(0U, access) || access.success || access.failure_code != 3U) { + return 10; + } + + auto bad_type_request = kWriteRequest; + bad_type_request[26] = 0x85U; + if (!mms::MmsServiceSpanCodec::try_decode_write_request( + bad_type_request, request) || + !table.try_resolve_write_request(request, resolved, resolved_count) || + resolved[0] == nullptr || !resolved[0]->writable() || + !request.try_value(0U, value)) { + return 11; + } + const auto rejected = resolved[0]->write(resolved[0]->write_context, value); + if (rejected.success || rejected.failure_code != 7U || relay_state) { + return 12; + } + + for (std::uint32_t iteration = 0U; iteration < 50'000U; ++iteration) { + const auto invoke = iteration & 0x7FFFU; + const auto encoded = mms::MmsServiceSpanCodec::encode_write_response_into( + invoke, (iteration & 1U) == 0U ? success : denied, buffer); + mms::MmsWriteResponseView decoded; + if (!encoded.success() || + !mms::MmsServiceSpanCodec::try_decode_write_response( + std::span{buffer}.first(encoded.bytes_written), + decoded) || + decoded.invoke_id != invoke || decoded.result_count != 1U || + !decoded.try_result(0U, access) || + access.success != ((iteration & 1U) == 0U)) { + return 13; + } + } + + return 0; +} diff --git a/embedded/osi_hard_profile_smoke.cpp b/embedded/osi_hard_profile_smoke.cpp new file mode 100644 index 000000000..cb4ff0d17 --- /dev/null +++ b/embedded/osi_hard_profile_smoke.cpp @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/osi/cotp_span.hpp" +#include "ariec61850/osi/tpkt_span.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850::osi; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +[[nodiscard]] bool parameter_matches( + const CotpTpduView& tpdu, + const std::uint8_t code, + const std::span expected) noexcept { + std::span value; + return tpdu.try_parameter(code, value) && + value.size() == expected.size() && + std::equal(value.begin(), value.end(), expected.begin()); +} + +} // namespace + +int main() { + // Existing desktop golden vector: TPKT wrapping a COTP Data TPDU prefix. + constexpr std::array tpkt_payload{ + 0x02U, 0xF0U, 0x80U, 0xA8U, 0x01U, 0x00U}; + constexpr std::array tpkt_golden{ + 0x03U, 0x00U, 0x00U, 0x0AU, + 0x02U, 0xF0U, 0x80U, 0xA8U, 0x01U, 0x00U}; + + std::array tpkt_buffer{}; + const auto tpkt_result = TpktSpanCodec::encode_into(tpkt_payload, tpkt_buffer); + if (!tpkt_result.success() || tpkt_result.bytes_written != tpkt_golden.size() || + !matches( + std::span{tpkt_buffer}.first(tpkt_result.bytes_written), + tpkt_golden)) { + return 1; + } + + TpktFrameView tpkt_view; + if (!TpktSpanCodec::try_decode_view(tpkt_golden, tpkt_view) || + tpkt_view.version != TpktSpanCodec::supported_version || + tpkt_view.declared_length != tpkt_golden.size() || + !matches(tpkt_view.payload, tpkt_payload)) { + return 2; + } + + if (TpktSpanCodec::peek_frame( + std::span{tpkt_golden}.first(2U)).status != + TpktPeekStatus::need_more) { + return 3; + } + + std::array coalesced{}; + std::copy(tpkt_golden.begin(), tpkt_golden.end(), coalesced.begin()); + coalesced.back() = 0xFFU; + const auto peek = TpktSpanCodec::peek_frame(coalesced); + if (!peek.ready() || peek.frame_bytes != tpkt_golden.size()) { + return 4; + } + + constexpr std::array invalid_tpkt{ + 0x02U, 0x00U, 0x00U, 0x04U}; + if (TpktSpanCodec::peek_frame(invalid_tpkt).status != TpktPeekStatus::invalid || + TpktSpanCodec::try_decode_view(invalid_tpkt, tpkt_view)) { + return 5; + } + + // Existing desktop golden vector: default COTP Connection Request. + constexpr std::array tpdu_size{0x0AU}; + constexpr std::array source_tsap{0x00U, 0x01U}; + constexpr std::array destination_tsap{0x00U, 0x01U}; + const std::array default_parameters{ + CotpParameterView{CotpSpanCodec::tpdu_size_parameter, tpdu_size}, + CotpParameterView{CotpSpanCodec::source_tsap_parameter, source_tsap}, + CotpParameterView{CotpSpanCodec::destination_tsap_parameter, destination_tsap}, + }; + constexpr std::array cr_golden{ + 0x11U, 0xE0U, 0x00U, 0x00U, 0x00U, 0x01U, 0x00U, + 0xC0U, 0x01U, 0x0AU, + 0xC1U, 0x02U, 0x00U, 0x01U, + 0xC2U, 0x02U, 0x00U, 0x01U}; + + std::array cr_buffer{}; + const auto cr_result = CotpSpanCodec::encode_connection_request_into( + 0x0001U, default_parameters, cr_buffer); + if (!cr_result.success() || cr_result.bytes_written != cr_golden.size() || + !matches( + std::span{cr_buffer}.first(cr_result.bytes_written), + cr_golden)) { + return 6; + } + + CotpTpduView cr_view; + if (!CotpSpanCodec::try_decode_view(cr_golden, cr_view) || + cr_view.kind != CotpWireKind::connection_request || + cr_view.destination_reference != 0x0000U || + cr_view.source_reference != 0x0001U || + !parameter_matches(cr_view, CotpSpanCodec::tpdu_size_parameter, tpdu_size) || + !parameter_matches(cr_view, CotpSpanCodec::source_tsap_parameter, source_tsap) || + !parameter_matches( + cr_view, CotpSpanCodec::destination_tsap_parameter, destination_tsap)) { + return 7; + } + + // Server-side CC must negotiate TPDU size and mirror TSAP values without + // constructing an owning parameter list. + constexpr std::array offered_size{0x09U}; + constexpr std::array offered_source{0x11U, 0x22U}; + constexpr std::array offered_destination{0x33U, 0x44U, 0x55U}; + const std::array offered_parameters{ + CotpParameterView{CotpSpanCodec::tpdu_size_parameter, offered_size}, + CotpParameterView{CotpSpanCodec::source_tsap_parameter, offered_source}, + CotpParameterView{CotpSpanCodec::destination_tsap_parameter, offered_destination}, + }; + + std::array request_buffer{}; + const auto request_result = CotpSpanCodec::encode_connection_request_into( + 0x0001U, offered_parameters, request_buffer); + CotpTpduView request_view; + if (!request_result.success() || + !CotpSpanCodec::try_decode_view( + std::span{request_buffer}.first(request_result.bytes_written), + request_view)) { + return 8; + } + + constexpr std::array cc_golden{ + 0x12U, 0xD0U, 0x00U, 0x01U, 0x10U, 0x01U, 0x00U, + 0xC0U, 0x01U, 0x09U, + 0xC2U, 0x02U, 0x11U, 0x22U, + 0xC1U, 0x03U, 0x33U, 0x44U, 0x55U}; + std::array cc_buffer{}; + const auto cc_result = CotpSpanCodec::encode_connection_confirm_from_request_into( + request_view, 0x1001U, cc_buffer, 0x0AU); + if (!cc_result.success() || cc_result.bytes_written != cc_golden.size() || + !matches( + std::span{cc_buffer}.first(cc_result.bytes_written), + cc_golden)) { + return 9; + } + + CotpTpduView cc_view; + if (!CotpSpanCodec::try_decode_view(cc_golden, cc_view) || + cc_view.kind != CotpWireKind::connection_confirm || + cc_view.destination_reference != 0x0001U || + cc_view.source_reference != 0x1001U || + !parameter_matches(cc_view, CotpSpanCodec::tpdu_size_parameter, offered_size) || + !parameter_matches(cc_view, CotpSpanCodec::source_tsap_parameter, offered_destination) || + !parameter_matches(cc_view, CotpSpanCodec::destination_tsap_parameter, offered_source)) { + return 10; + } + + constexpr std::array cotp_user_data{ + 0x0DU, 0x01U, 0x02U, 0x03U}; + constexpr std::array cotp_data_golden{ + 0x02U, 0xF0U, 0x80U, 0x0DU, 0x01U, 0x02U, 0x03U}; + std::array cotp_data_buffer{}; + const auto data_result = CotpSpanCodec::encode_data_into( + cotp_user_data, cotp_data_buffer); + if (!data_result.success() || data_result.bytes_written != cotp_data_golden.size() || + !matches( + std::span{cotp_data_buffer}.first(data_result.bytes_written), + cotp_data_golden)) { + return 11; + } + + CotpTpduView data_view; + if (!CotpSpanCodec::try_decode_view(cotp_data_golden, data_view) || + data_view.kind != CotpWireKind::data || + !data_view.end_of_transmission || data_view.tpdu_number != 0U || + !matches(data_view.user_data, cotp_user_data)) { + return 12; + } + + // Complete zero-copy transport stack: COTP Data -> TPKT -> view decode -> + // COTP view decode, retaining the same caller-owned storage throughout. + std::array transport_buffer{}; + const auto transport_result = TpktSpanCodec::encode_into( + std::span{cotp_data_buffer}.first(data_result.bytes_written), + transport_buffer); + TpktFrameView transport_view; + CotpTpduView nested_data_view; + if (!transport_result.success() || + !TpktSpanCodec::try_decode_view( + std::span{transport_buffer}.first( + transport_result.bytes_written), + transport_view) || + !CotpSpanCodec::try_decode_view(transport_view.payload, nested_data_view) || + !matches(nested_data_view.user_data, cotp_user_data)) { + return 13; + } + + // Exercise the steady-state bounded path repeatedly. No owning container is + // constructed by either codec during these encode/decode iterations. + for (std::uint32_t iteration = 0U; iteration < 50'000U; ++iteration) { + const auto encoded = CotpSpanCodec::encode_data_into( + cotp_user_data, cotp_data_buffer, true, + static_cast(iteration & 0x7FU)); + if (!encoded.success()) { + return 14; + } + CotpTpduView decoded; + if (!CotpSpanCodec::try_decode_view( + std::span{cotp_data_buffer}.first( + encoded.bytes_written), + decoded) || + decoded.tpdu_number != static_cast(iteration & 0x7FU)) { + return 15; + } + } + + constexpr std::array malformed_parameter{ + 0x09U, 0xE0U, 0x00U, 0x00U, 0x00U, 0x01U, 0x00U, + 0xC0U, 0x02U, 0x0AU}; + if (CotpSpanCodec::try_decode_view(malformed_parameter, cr_view)) { + return 16; + } + + return 0; +} diff --git a/embedded/session_presentation_hard_profile_smoke.cpp b/embedded/session_presentation_hard_profile_smoke.cpp new file mode 100644 index 000000000..c43cde56e --- /dev/null +++ b/embedded/session_presentation_hard_profile_smoke.cpp @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/osi/presentation_span.hpp" +#include "ariec61850/osi/session_span.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +template +[[nodiscard]] bool matches( + const std::span actual, + const std::array& expected) noexcept { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +[[nodiscard]] bool parameter_matches( + const osi::SessionSpduView& spdu, + const std::uint8_t code, + const std::span expected) noexcept { + std::span value; + return spdu.try_parameter(code, value) && + value.size() == expected.size() && + std::equal(value.begin(), value.end(), expected.begin()); +} + +} // namespace + +int main() { + constexpr std::array mms_payload{ + 0xA0U, 0x08U, 0x02U, 0x01U, 0x07U, + 0xA4U, 0x03U, 0x80U, 0x01U, 0x00U}; + constexpr std::array presentation_golden{ + 0x61U, 0x11U, 0x30U, 0x0FU, + 0x02U, 0x01U, 0x03U, + 0xA0U, 0x0AU, + 0xA0U, 0x08U, 0x02U, 0x01U, 0x07U, + 0xA4U, 0x03U, 0x80U, 0x01U, 0x00U}; + + // BER reader stays zero-copy and rejects indefinite-length BER. + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(presentation_golden, outer) || + outer.tag_class != asn1::BerClass::application || + outer.tag_number != 1 || !outer.constructed) { + return 1; + } + constexpr std::array indefinite{ + 0x61U, 0x80U, 0x00U, 0x00U}; + if (asn1::BerSpanReader::try_read_exact(indefinite, outer)) { + return 2; + } + + // Presentation fully-encoded-data must match the existing host encoding. + std::array presentation_buffer{}; + const auto presentation_result = + osi::PresentationSpanCodec::encode_fully_encoded_data_into( + 3U, mms_payload, presentation_buffer); + if (!presentation_result.success() || + presentation_result.bytes_written != presentation_golden.size() || + !matches( + std::span{presentation_buffer}.first( + presentation_result.bytes_written), + presentation_golden)) { + return 3; + } + + osi::PresentationPdvView pdv; + if (!osi::PresentationSpanCodec::try_decode_fully_encoded_data_view( + presentation_golden, pdv) || + pdv.context_id != 3U || !matches(pdv.single_asn1_type, mms_payload)) { + return 4; + } + + // Default Session parameters are byte-identical to the established host + // profile. The bounded encoder appends Presentation bytes as final C1 data. + constexpr std::array parameter05{ + 0x13U, 0x01U, 0x00U, 0x16U, 0x01U, 0x02U}; + constexpr std::array parameter14{0x00U, 0x02U}; + constexpr std::array parameter33{0x00U, 0x01U}; + constexpr std::array parameter34{0x00U, 0x01U}; + constexpr std::array session_connect_golden{ + 0x0DU, 0x29U, + 0x05U, 0x06U, 0x13U, 0x01U, 0x00U, 0x16U, 0x01U, 0x02U, + 0x14U, 0x02U, 0x00U, 0x02U, + 0x33U, 0x02U, 0x00U, 0x01U, + 0x34U, 0x02U, 0x00U, 0x01U, + 0xC1U, 0x13U, + 0x61U, 0x11U, 0x30U, 0x0FU, + 0x02U, 0x01U, 0x03U, + 0xA0U, 0x0AU, + 0xA0U, 0x08U, 0x02U, 0x01U, 0x07U, + 0xA4U, 0x03U, 0x80U, 0x01U, 0x00U}; + + std::array session_buffer{}; + const auto connect_result = osi::SessionSpanCodec::encode_connect_into( + presentation_golden, session_buffer); + if (!connect_result.success() || + connect_result.bytes_written != session_connect_golden.size() || + !matches( + std::span{session_buffer}.first( + connect_result.bytes_written), + session_connect_golden)) { + return 5; + } + + osi::SessionSpduView connect_view; + if (!osi::SessionSpanCodec::try_decode_view( + session_connect_golden, connect_view) || + connect_view.kind != osi::SessionWireKind::connect || + !matches(connect_view.user_data, presentation_golden) || + !parameter_matches(connect_view, 0x05U, parameter05) || + !parameter_matches(connect_view, 0x14U, parameter14) || + !parameter_matches(connect_view, 0x33U, parameter33) || + !parameter_matches(connect_view, 0x34U, parameter34)) { + return 6; + } + + constexpr std::array session_accept_golden{ + 0x0EU, 0x29U, + 0x05U, 0x06U, 0x13U, 0x01U, 0x00U, 0x16U, 0x01U, 0x02U, + 0x14U, 0x02U, 0x00U, 0x02U, + 0x33U, 0x02U, 0x00U, 0x01U, + 0x34U, 0x02U, 0x00U, 0x01U, + 0xC1U, 0x13U, + 0x61U, 0x11U, 0x30U, 0x0FU, + 0x02U, 0x01U, 0x03U, + 0xA0U, 0x0AU, + 0xA0U, 0x08U, 0x02U, 0x01U, 0x07U, + 0xA4U, 0x03U, 0x80U, 0x01U, 0x00U}; + const auto accept_result = osi::SessionSpanCodec::encode_accept_mirroring_into( + connect_view, presentation_golden, session_buffer); + if (!accept_result.success() || + accept_result.bytes_written != session_accept_golden.size() || + !matches( + std::span{session_buffer}.first( + accept_result.bytes_written), + session_accept_golden)) { + return 7; + } + + // Existing Session data-transfer profile: optional Give-Tokens prefix plus + // the Data-Transfer SPDU, followed directly by Presentation bytes. + constexpr std::array p_data_golden{ + 0x01U, 0x00U, 0x01U, 0x00U, + 0x61U, 0x11U, 0x30U, 0x0FU, + 0x02U, 0x01U, 0x03U, + 0xA0U, 0x0AU, + 0xA0U, 0x08U, 0x02U, 0x01U, 0x07U, + 0xA4U, 0x03U, 0x80U, 0x01U, 0x00U}; + + const auto p_data_result = osi::PresentationSpanCodec::encode_p_data_into( + mms_payload, presentation_buffer, 3U, true); + if (!p_data_result.success() || + p_data_result.bytes_written != p_data_golden.size() || + !matches( + std::span{presentation_buffer}.first( + p_data_result.bytes_written), + p_data_golden)) { + return 8; + } + + if (!osi::PresentationSpanCodec::try_decode_p_data_view(p_data_golden, pdv) || + pdv.context_id != 3U || !matches(pdv.single_asn1_type, mms_payload)) { + return 9; + } + + osi::SessionDataTransferView transfer; + if (!osi::SessionSpanCodec::try_decode_data_transfer_view( + p_data_golden, transfer) || + !transfer.has_give_tokens_prefix || + !matches(transfer.presentation_payload, presentation_golden)) { + return 10; + } + + // Direct Presentation decode remains accepted when the Session wrapper has + // already been removed by the transport state machine. + if (!osi::PresentationSpanCodec::try_decode_p_data_view( + presentation_golden, pdv) || + pdv.context_id != 3U || !matches(pdv.single_asn1_type, mms_payload)) { + return 11; + } + + // Steady-state encode/decode regression: no owning container is created by + // BER, Session, or Presentation bounded APIs. + for (std::uint32_t iteration = 0U; iteration < 50'000U; ++iteration) { + const auto context_id = (iteration & 1U) == 0U ? 3U : 5U; + const auto encoded = osi::PresentationSpanCodec::encode_p_data_into( + mms_payload, presentation_buffer, context_id, true); + if (!encoded.success()) { + return 12; + } + osi::PresentationPdvView decoded; + if (!osi::PresentationSpanCodec::try_decode_p_data_view( + std::span{presentation_buffer}.first( + encoded.bytes_written), + decoded) || + decoded.context_id != context_id || + !matches(decoded.single_asn1_type, mms_payload)) { + return 13; + } + } + + return 0; +} diff --git a/embedded/sv_hard_profile_smoke.cpp b/embedded/sv_hard_profile_smoke.cpp new file mode 100644 index 000000000..ba106b2f8 --- /dev/null +++ b/embedded/sv_hard_profile_smoke.cpp @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/embedded/io.hpp" +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/sampled_values/frame.hpp" +#include "ariec61850/sampled_values/publisher.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +struct FakeEthernet final { + std::array last_frame{}; + std::size_t last_size{}; + std::uint64_t sends{}; +}; + +embedded::IoResult capture_frame( + void* context, + const std::span frame) noexcept { + auto* fake = static_cast(context); + if (fake == nullptr || frame.size() > fake->last_frame.size()) { + return {embedded::IoStatus::invalid_argument, 0U}; + } + + std::copy(frame.begin(), frame.end(), fake->last_frame.begin()); + fake->last_size = frame.size(); + ++fake->sends; + return {embedded::IoStatus::ok, frame.size()}; +} + +sampled_values::SampledValuesFrame make_frame() { + const std::array destination{ + 0x01U, 0x0CU, 0xCDU, 0x04U, 0x00U, 0x01U}; + const std::array source{ + 0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x02U}; + + sampled_values::SampledValueAsdu asdu; + asdu.sv_id = "ARSTACK61850_HARD_PROFILE"; + asdu.data_set_reference = "ARSTACK61850/LLN0$PhsMeas1"; + asdu.configuration_revision = 1U; + asdu.sample_synchronization = 0U; + asdu.sample_rate = std::uint16_t{4'000U}; + asdu.sample_mode = std::uint16_t{1U}; + asdu.sample_payload.resize(64U, 0U); + + return { + ethernet::MacAddress{destination}, + ethernet::MacAddress{source}, + std::nullopt, + 0x4001U, + 0U, + 0U, + sampled_values::SampledValuesPdu{{asdu}}}; +} + +bool bounded_ethernet_primitives_work() { + const std::array raw_mac{ + 0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x02U}; + ethernet::MacAddress parsed{}; + if (!ethernet::MacAddress::try_from_bytes(raw_mac, parsed) || + parsed.bytes() != raw_mac) { + return false; + } + + std::array copied{}; + if (!parsed.try_copy_to(copied) || copied != raw_mac) { + return false; + } + + ethernet::VlanTag vlan{4U, true, 100U}; + std::uint16_t tci{}; + if (!vlan.try_to_tag_control_information(tci) || tci != 0x9064U) { + return false; + } + ethernet::VlanTag invalid_vlan{8U, false, 100U}; + if (invalid_vlan.try_to_tag_control_information(tci)) { + return false; + } + + const std::array apdu{0x61U, 0x01U, 0x00U}; + std::array process_bus{}; + const auto process_result = ethernet::ProcessBusFrameCodec::encode_payload_into( + 0x1001U, + apdu, + process_bus, + 0U, + 0U); + if (!process_result.success() || process_result.bytes_written != 11U || + process_bus[0] != 0x10U || process_bus[1] != 0x01U || + process_bus[2] != 0x00U || process_bus[3] != 0x0BU || + process_bus[8] != 0x61U) { + return false; + } + + const std::array destination{ + 0x01U, 0x0CU, 0xCDU, 0x01U, 0x00U, 0x01U}; + ethernet::EthernetFrame frame{ + ethernet::MacAddress{destination}, + parsed, + ethernet::goose_ethertype, + vlan, + std::vector{process_bus.begin(), process_bus.begin() + 11}}; + std::array wire{}; + const auto ethernet_result = ethernet::EthernetFrameCodec::encode_into(frame, wire); + if (!ethernet_result.success() || ethernet_result.bytes_written != 29U || + wire[12] != 0x81U || wire[13] != 0x00U || + wire[16] != 0x88U || wire[17] != 0xB8U) { + return false; + } + + return true; +} + +} // namespace + +int main() { + if (!bounded_ethernet_primitives_work()) { + return 1; + } + + FakeEthernet fake; + embedded::RawEthernetPort port{&fake, &capture_frame}; + auto frame = make_frame(); + std::array frame_buffer{}; + + const auto asdu_capacity = frame.pdu.asdus.capacity(); + const auto payload_capacity = frame.pdu.asdus.front().sample_payload.capacity(); + const auto sv_id_capacity = frame.pdu.asdus.front().sv_id.capacity(); + const auto dataset_capacity = frame.pdu.asdus.front().data_set_reference.capacity(); + + sampled_values::SampledValuesPublisher publisher( + frame, + frame_buffer, + port, + sampled_values::SampledValuesPublisherConfig{ + 4'000U, + std::uint16_t{4'000U}, + 0U, + true}); + if (!publisher.valid()) { + return 2; + } + + constexpr std::uint64_t start_us = 1'000'000U; + constexpr std::uint64_t frame_count = 8'000U; + for (std::uint64_t index = 0U; index < frame_count; ++index) { + const auto result = publisher.poll(start_us + index * 250U); + const auto expected_count = static_cast(index % 4'000U); + if (!result.sent() || result.sample_count != expected_count || + fake.sends != index + 1U) { + return 3; + } + } + + const auto& stats = publisher.statistics(); + if (stats.frames_sent != frame_count || stats.encode_failures != 0U || + stats.transmit_failures != 0U || stats.late_polls != 0U || + stats.maximum_lateness_us != 0U || publisher.next_sample_count() != 0U) { + return 4; + } + + // The current hard-profile milestone permits setup-time dynamic storage in + // the legacy frame model, but the 4 kHz steady-state path must not grow it. + if (frame.pdu.asdus.capacity() != asdu_capacity || + frame.pdu.asdus.front().sample_payload.capacity() != payload_capacity || + frame.pdu.asdus.front().sv_id.capacity() != sv_id_capacity || + frame.pdu.asdus.front().data_set_reference.capacity() != dataset_capacity) { + return 5; + } + + if (fake.last_size < 22U || + fake.last_frame[12] != 0x88U || fake.last_frame[13] != 0xBAU || + fake.last_frame[14] != 0x40U || fake.last_frame[15] != 0x01U) { + return 6; + } + + return 0; +} diff --git a/embedded/sv_injector_controller_smoke.cpp b/embedded/sv_injector_controller_smoke.cpp new file mode 100644 index 000000000..6f75285b0 --- /dev/null +++ b/embedded/sv_injector_controller_smoke.cpp @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/sampled_values/injector_control_protocol.hpp" +#include "ariec61850/sampled_values/injector_controller.hpp" + +#include + +namespace { + +using namespace ar::iec61850::sampled_values; + +[[nodiscard]] int fail(const int code) noexcept { return code; } + +} // namespace + +int main() { + const auto status_command = parse_injector_control_command( + R"({"command":"status"})"); + if (!status_command.success() || + status_command.command.kind != InjectorControlCommandKind::status) { + return fail(1); + } + + const auto configure_command = parse_injector_control_command( + R"( { "command" : "configure", "scenario" : "protection-fault" } )"); + if (!configure_command.success() || + configure_command.command.kind != InjectorControlCommandKind::configure || + configure_command.command.scenario != InjectorScenarioKind::protection_fault) { + return fail(2); + } + + if (parse_injector_control_command(R"({"command":"configure"})").status != + InjectorControlParseStatus::missing_scenario) { + return fail(3); + } + if (parse_injector_control_command(R"({"command":"launch"})").status != + InjectorControlParseStatus::unsupported_command) { + return fail(4); + } + if (parse_injector_control_command("status").status != + InjectorControlParseStatus::malformed) { + return fail(5); + } + + const auto live_edit = parse_injector_control_command( + R"({"command":"set-channel","channel":"Ia","rms":2500,"phaseMilliDeg":15000,"frequencyMilliHz":50000})"); + if (!live_edit.success() || + live_edit.command.kind != InjectorControlCommandKind::set_channel || + live_edit.command.channel_edit.channel_index != 0U || + live_edit.command.channel_edit.rms_counts != 2'500 || + live_edit.command.channel_edit.phase_millidegrees != 15'000 || + live_edit.command.channel_edit.frequency_millihz != 50'000U || + (live_edit.command.channel_edit.fields & injector_field_rms) == 0U || + (live_edit.command.channel_edit.fields & injector_field_phase) == 0U || + (live_edit.command.channel_edit.fields & injector_field_frequency) == 0U) { + return fail(6); + } + + const auto ramp_edit = parse_injector_control_command( + R"({"command":"ramp-channel","channel":"Va","rms":9000,"durationSamples":4000})"); + if (!ramp_edit.success() || + ramp_edit.command.kind != InjectorControlCommandKind::ramp_channel || + ramp_edit.command.channel_edit.channel_index != 4U || + ramp_edit.command.channel_edit.duration_samples != 4'000U || + ramp_edit.command.channel_edit.rms_counts != 9'000) { + return fail(7); + } + + if (parse_injector_control_command( + R"({"command":"set-channel","channel":"Ix","rms":1000})") + .status != InjectorControlParseStatus::unsupported_channel) { + return fail(8); + } + if (parse_injector_control_command( + R"({"command":"set-channel","channel":"Ia"})") + .status != InjectorControlParseStatus::missing_value) { + return fail(9); + } + if (parse_injector_control_command( + R"({"command":"ramp-channel","channel":"Ia","rms":2000,"durationSamples":1})") + .status != InjectorControlParseStatus::invalid_value) { + return fail(10); + } + + const auto sequence_begin = parse_injector_control_command( + R"({"command":"sequence-begin"})"); + if (!sequence_begin.success() || + sequence_begin.command.kind != InjectorControlCommandKind::sequence_begin) { + return fail(11); + } + + const auto state_begin = parse_injector_control_command( + R"({"command":"sequence-state-begin","durationSamples":800,"transition":"linear"})"); + if (!state_begin.success() || + state_begin.command.kind != InjectorControlCommandKind::sequence_state_begin || + state_begin.command.duration_samples != 800U || + state_begin.command.transition != InjectorSegmentTransition::linear_from_previous) { + return fail(12); + } + + const auto sequence_edit = parse_injector_control_command( + R"({"command":"sequence-set-channel","channel":"Ic","rms":4200,"phaseMilliDeg":120000})"); + if (!sequence_edit.success() || + sequence_edit.command.kind != InjectorControlCommandKind::sequence_set_channel || + sequence_edit.command.channel_edit.channel_index != 2U || + sequence_edit.command.channel_edit.rms_counts != 4'200 || + sequence_edit.command.channel_edit.phase_millidegrees != 120'000) { + return fail(13); + } + + if (parse_injector_control_command( + R"({"command":"sequence-state-begin","durationSamples":0})") + .status != InjectorControlParseStatus::invalid_value) { + return fail(14); + } + if (parse_injector_control_command( + R"({"command":"sequence-state-begin","durationSamples":80,"transition":"curve"})") + .status != InjectorControlParseStatus::unsupported_transition) { + return fail(15); + } + + const auto state_commit = parse_injector_control_command( + R"({"command":"sequence-state-commit"})"); + const auto sequence_commit = parse_injector_control_command( + R"({"command":"sequence-commit"})"); + const auto sequence_abort = parse_injector_control_command( + R"({"command":"sequence-abort"})"); + if (!state_commit.success() || !sequence_commit.success() || !sequence_abort.success() || + state_commit.command.kind != InjectorControlCommandKind::sequence_state_commit || + sequence_commit.command.kind != InjectorControlCommandKind::sequence_commit || + sequence_abort.command.kind != InjectorControlCommandKind::sequence_abort) { + return fail(16); + } + + InjectorController controller; + if (controller.state() != InjectorControlState::idle) { + return fail(17); + } + if (controller.start() != InjectorControlStatus::invalid_state) { + return fail(18); + } + + if (controller.configure({0U, InjectorScenarioKind::normal}) != + InjectorControlStatus::invalid_configuration) { + return fail(19); + } + if (controller.configure({4'000U, InjectorScenarioKind::normal}) != + InjectorControlStatus::ok) { + return fail(20); + } + + const auto configured = controller.snapshot(); + if (configured.state != InjectorControlState::configured || + configured.configuration_revision != 1U || + configured.armed_revision != 0U) { + return fail(21); + } + + if (controller.arm() != InjectorControlStatus::ok) { + return fail(22); + } + const auto armed = controller.snapshot(); + if (armed.state != InjectorControlState::armed || + armed.armed_revision != armed.configuration_revision) { + return fail(23); + } + if (controller.configure({4'000U, InjectorScenarioKind::protection_fault}) != + InjectorControlStatus::invalid_state) { + return fail(24); + } + + if (controller.start() != InjectorControlStatus::ok || !controller.running()) { + return fail(25); + } + const auto first_run = controller.snapshot(); + if (first_run.run_sequence != 1U) { + return fail(26); + } + if (controller.arm() != InjectorControlStatus::invalid_state) { + return fail(27); + } + + if (controller.stop() != InjectorControlStatus::ok || + controller.state() != InjectorControlState::stopped) { + return fail(28); + } + if (controller.arm() != InjectorControlStatus::ok || + controller.start() != InjectorControlStatus::ok) { + return fail(29); + } + if (controller.snapshot().run_sequence != 2U) { + return fail(30); + } + + controller.set_fault(); + if (controller.state() != InjectorControlState::fault) { + return fail(31); + } + if (controller.start() != InjectorControlStatus::invalid_state) { + return fail(32); + } + + if (controller.configure({4'000U, InjectorScenarioKind::normal}) != + InjectorControlStatus::ok) { + return fail(33); + } + const auto recovered = controller.snapshot(); + if (recovered.state != InjectorControlState::configured || + recovered.configuration_revision != 2U || + recovered.armed_revision != 0U) { + return fail(34); + } + + return 0; +} diff --git a/embedded/sv_injector_smoke.cpp b/embedded/sv_injector_smoke.cpp new file mode 100644 index 000000000..de9758f1f --- /dev/null +++ b/embedded/sv_injector_smoke.cpp @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/sampled_values/deterministic_injector.hpp" +#include "ariec61850/sampled_values/injector_presets.hpp" + +#include +#include + +namespace { + +using namespace ar::iec61850::sampled_values; + +bool exact_cycle_smoke() noexcept { + const auto channels = make_balanced_4i4v_profile(1'000, 5'774, 50'000U); + const std::array segments{ + make_hold_segment(channels)}; + DeterministicSvInjector injector(segments, 4'000U, false); + if (!injector.valid()) { + return false; + } + + InjectorSample sample{}; + std::array values{}; + for (std::size_t index = 0U; index < values.size(); ++index) { + if (!injector.step(sample)) { + return false; + } + values[index] = sample.values[0]; + } + + return values[0] == 0 && + values[20] >= 1'408 && values[20] <= 1'420 && + values[40] >= -2 && values[40] <= 2 && + values[60] <= -1'408 && values[60] >= -1'420 && + values[80] >= -2 && values[80] <= 2; +} + +bool reset_is_byte_reproducible() noexcept { + const auto channels = make_balanced_4i4v_profile(); + const std::array segments{ + make_hold_segment(channels)}; + DeterministicSvInjector injector(segments, 4'000U, true); + if (!injector.valid()) { + return false; + } + + std::array first{}; + std::array second{}; + InjectorSample sample{}; + for (auto& value : first) { + if (!injector.step(sample)) { + return false; + } + value = sample.values[4]; + } + + injector.reset(); + for (auto& value : second) { + if (!injector.step(sample)) { + return false; + } + value = sample.values[4]; + } + return first == second; +} + +bool linear_segment_uses_integer_sample_timeline() noexcept { + auto low = make_balanced_4i4v_profile(1'000, 1'000, 0U); + auto high = low; + for (auto& channel : low) { + channel.phase_millidegrees = 90'000; + } + for (auto& channel : high) { + channel.phase_millidegrees = 90'000; + } + high[0].rms_counts = 4'000; + + const std::array segments{ + InjectorScenarioSegment{1U, InjectorSegmentTransition::step, low}, + InjectorScenarioSegment{3U, InjectorSegmentTransition::linear_from_previous, high}}; + DeterministicSvInjector injector(segments, 4'000U, false); + if (!injector.valid()) { + return false; + } + + InjectorSample sample{}; + std::array values{}; + for (auto& value : values) { + if (!injector.step(sample)) { + return false; + } + value = sample.values[0]; + } + + return values[0] >= 1'408 && values[0] <= 1'420 && + values[1] >= 1'408 && values[1] <= 1'420 && + values[2] > values[1] && + values[3] > values[2] && + values[3] >= 5'650 && values[3] <= 5'665 && + injector.finished(); +} + +} // namespace + +int main() { + if (!exact_cycle_smoke()) { + return 1; + } + if (!reset_is_byte_reproducible()) { + return 2; + } + if (!linear_segment_uses_integer_sample_timeline()) { + return 3; + } + return 0; +} diff --git a/embedded/sv_publisher_smoke.cpp b/embedded/sv_publisher_smoke.cpp new file mode 100644 index 000000000..204961917 --- /dev/null +++ b/embedded/sv_publisher_smoke.cpp @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/embedded/io.hpp" +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/sampled_values/frame.hpp" +#include "ariec61850/sampled_values/frame_codec.hpp" +#include "ariec61850/sampled_values/publisher.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +struct FakeEthernet final { + std::array last_frame{}; + std::size_t last_size{}; + std::uint64_t sends{}; + bool fail_next{}; +}; + +embedded::IoResult capture_frame( + void* context, + const std::span frame) noexcept { + auto* fake = static_cast(context); + if (fake == nullptr || frame.size() > fake->last_frame.size()) { + return {embedded::IoStatus::invalid_argument, 0U}; + } + if (fake->fail_next) { + fake->fail_next = false; + return {embedded::IoStatus::timeout, 0U}; + } + + std::copy(frame.begin(), frame.end(), fake->last_frame.begin()); + fake->last_size = frame.size(); + ++fake->sends; + return {embedded::IoStatus::ok, frame.size()}; +} + +sampled_values::SampledValuesFrame make_frame() { + const std::array destination{ + 0x01U, 0x0CU, 0xCDU, 0x04U, 0x00U, 0x01U}; + const std::array source{ + 0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x02U}; + + sampled_values::SampledValueAsdu asdu; + asdu.sv_id = "ESP32P4/LLN0$MSVCB01"; + asdu.data_set_reference = "ESP32P4/LLN0$PhsMeas1"; + asdu.configuration_revision = 1U; + asdu.sample_synchronization = 0U; + asdu.sample_rate = std::uint16_t{4'000U}; + asdu.sample_mode = std::uint16_t{1U}; + asdu.sample_payload = { + 0x00U, 0x00U, 0x00U, 0x64U, + 0x00U, 0x00U, 0x00U, 0x00U, + 0x00U, 0x00U, 0x00U, 0xC8U, + 0x00U, 0x00U, 0x00U, 0x00U}; + + return { + ethernet::MacAddress{destination}, + ethernet::MacAddress{source}, + std::nullopt, + 0x4001U, + 0U, + 0U, + sampled_values::SampledValuesPdu{{asdu}}}; +} + +} // namespace + +int main() { + using namespace ar::iec61850; + + FakeEthernet fake; + embedded::RawEthernetPort port{&fake, &capture_frame}; + auto frame = make_frame(); + std::array frame_buffer{}; + + sampled_values::SampledValuesPublisher publisher( + frame, + frame_buffer, + port, + sampled_values::SampledValuesPublisherConfig{ + 4'000U, + std::uint16_t{4'000U}, + 3'999U, + true}); + + if (!publisher.valid()) { + return 1; + } + + const auto first = publisher.poll(1'000U); + if (!first.sent() || first.sample_count != 3'999U || + publisher.next_sample_count() != 0U || first.next_due_us != 1'250U || + fake.sends != 1U) { + return 2; + } + + sampled_values::SampledValuesFrame decoded; + if (!sampled_values::SampledValuesFrameCodec::try_decode( + std::span{fake.last_frame}.first(fake.last_size), + decoded) || + decoded.pdu.asdus.size() != 1U || + decoded.pdu.asdus.front().sample_count != 3'999U || + decoded.app_id != 0x4001U || + decoded.vlan.has_value()) { + return 3; + } + + const auto early = publisher.poll(1'249U); + if (early.status != sampled_values::SampledValuesPublishStatus::not_due || + fake.sends != 1U) { + return 4; + } + + const auto wrapped = publisher.poll(1'250U); + if (!wrapped.sent() || wrapped.sample_count != 0U || + publisher.next_sample_count() != 1U || fake.sends != 2U) { + return 5; + } + + // Arrive very late. Exactly one frame may be sent, then the next deadline is + // anchored 250 us after the actual send. This prevents scheduler catch-up bursts. + const auto late = publisher.poll(2'000U); + if (!late.sent() || late.lateness_us != 500U || + late.next_due_us != 2'250U || fake.sends != 3U) { + return 6; + } + const auto no_catch_up = publisher.poll(2'001U); + if (no_catch_up.status != sampled_values::SampledValuesPublishStatus::not_due || + fake.sends != 3U) { + return 7; + } + + const auto before_failure = publisher.next_sample_count(); + fake.fail_next = true; + const auto failed = publisher.publish_now(2'100U); + if (failed.status != sampled_values::SampledValuesPublishStatus::transmit_failed || + failed.io_status != embedded::IoStatus::timeout || + publisher.next_sample_count() != before_failure || + fake.sends != 3U) { + return 8; + } + + auto small_frame = make_frame(); + std::array small_buffer{}; + sampled_values::SampledValuesPublisher too_small( + small_frame, + small_buffer, + port, + sampled_values::SampledValuesPublisherConfig{}); + const auto encoding_failure = too_small.publish_now(3'000U); + if (encoding_failure.status != sampled_values::SampledValuesPublishStatus::encode_failed || + encoding_failure.encode_status != wire::EncodeStatus::buffer_too_small) { + return 9; + } + + const auto& statistics = publisher.statistics(); + if (statistics.frames_sent != 3U || statistics.transmit_failures != 1U || + statistics.late_polls != 1U || statistics.maximum_lateness_us != 500U) { + return 10; + } + + // Simulate two full seconds at the first-trial 4 kHz profile. This is not a + // wall-clock performance benchmark; it is deterministic protocol/pacing QA. + // Every nominal 250 us tick must produce exactly one frame, smpCnt must wrap + // at 4000, the resulting wire frame must decode, and the stream must report + // no scheduler lateness when the simulated clock is exact. + FakeEthernet stream_fake; + embedded::RawEthernetPort stream_port{&stream_fake, &capture_frame}; + auto stream_frame = make_frame(); + std::array stream_buffer{}; + sampled_values::SampledValuesPublisher stream_publisher( + stream_frame, + stream_buffer, + stream_port, + sampled_values::SampledValuesPublisherConfig{ + 4'000U, + std::uint16_t{4'000U}, + 0U, + true}); + + constexpr std::uint64_t stream_start_us = 1'000'000U; + constexpr std::uint64_t simulated_frames = 8'000U; + for (std::uint64_t index = 0U; index < simulated_frames; ++index) { + const auto now_us = stream_start_us + index * 250U; + const auto expected_count = static_cast(index % 4'000U); + const auto result = stream_publisher.poll(now_us); + if (!result.sent() || result.sample_count != expected_count || + stream_fake.sends != index + 1U) { + return 11; + } + + sampled_values::SampledValuesFrame stream_decoded; + if (!sampled_values::SampledValuesFrameCodec::try_decode( + std::span{stream_fake.last_frame}.first(stream_fake.last_size), + stream_decoded) || + stream_decoded.destination != stream_frame.destination || + stream_decoded.source != stream_frame.source || + stream_decoded.app_id != 0x4001U || + stream_decoded.vlan.has_value() || + stream_decoded.pdu.asdus.size() != 1U || + stream_decoded.pdu.asdus.front().sample_count != expected_count || + stream_decoded.pdu.asdus.front().sample_payload != + stream_frame.pdu.asdus.front().sample_payload) { + return 12; + } + } + + const auto& stream_statistics = stream_publisher.statistics(); + if (stream_statistics.frames_sent != simulated_frames || + stream_statistics.encode_failures != 0U || + stream_statistics.transmit_failures != 0U || + stream_statistics.late_polls != 0U || + stream_statistics.maximum_lateness_us != 0U || + stream_publisher.next_sample_count() != 0U) { + return 13; + } + + return 0; +} diff --git a/embedded/sv_runtime_program_smoke.cpp b/embedded/sv_runtime_program_smoke.cpp new file mode 100644 index 000000000..8ce7d29e5 --- /dev/null +++ b/embedded/sv_runtime_program_smoke.cpp @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/sampled_values/deterministic_injector.hpp" +#include "ariec61850/sampled_values/injector_presets.hpp" +#include "ariec61850/sampled_values/injector_profile_builder.hpp" +#include "ariec61850/sampled_values/injector_replay_ring.hpp" +#include "ariec61850/sampled_values/injector_runtime_program.hpp" +#include "ariec61850/sampled_values/injector_sequence_builder.hpp" +#include "ariec61850/sampled_values/replay_bundle.hpp" + +#include +#include + +namespace { + +using namespace ar::iec61850::sampled_values; + +[[nodiscard]] InjectorRuntimeProgram::ChannelProfiles constant_profile( + const std::int32_t ia_rms, + const std::int32_t ia_phase_millidegrees = 90'000) noexcept { + auto channels = make_balanced_4i4v_profile(ia_rms, 5'774, 0U); + for (auto& channel : channels) { + channel.frequency_millihz = 0U; + } + channels[0].phase_millidegrees = ia_phase_millidegrees; + return channels; +} + +[[nodiscard]] bool near(const std::int32_t value, const std::int32_t expected) noexcept { + const auto difference = value > expected ? value - expected : expected - value; + return difference <= 8; +} + +bool hot_manual_update_preserves_timeline() noexcept { + InjectorRuntimeProgram program(constant_profile(1'000)); + DeterministicSvInjector injector(program.segments(), 4'000U, true); + if (!injector.valid()) { + return false; + } + + InjectorSample sample{}; + for (std::uint64_t index = 0U; index < 10U; ++index) { + if (!injector.step(sample) || sample.sample_index != index || + !near(sample.values[0], 1'414)) { + return false; + } + } + + if (!program.stage_manual(injector.segment_index(), constant_profile(2'000))) { + return false; + } + + if (!injector.step(sample) || sample.sample_index != 10U || + !near(sample.values[0], 1'414)) { + return false; + } + if (!injector.step(sample) || sample.sample_index != 11U || + !near(sample.values[0], 2'828)) { + return false; + } + + if (!program.stage_manual(injector.segment_index(), constant_profile(2'000, 0))) { + return false; + } + if (!injector.step(sample) || sample.sample_index != 12U || + !near(sample.values[0], 2'828)) { + return false; + } + return injector.step(sample) && sample.sample_index == 13U && + near(sample.values[0], 0); +} + +bool ramp_is_sample_indexed_and_holds_target() noexcept { + InjectorRuntimeProgram program(constant_profile(1'000)); + DeterministicSvInjector injector(program.segments(), 4'000U, true); + if (!injector.valid()) { + return false; + } + + InjectorSample sample{}; + if (!injector.step(sample)) { + return false; + } + if (!program.stage_ramp( + injector.segment_index(), constant_profile(4'000), 3U)) { + return false; + } + + std::array observed{}; + for (auto& value : observed) { + if (!injector.step(sample)) { + return false; + } + value = sample.values[0]; + } + + return near(observed[0], 1'414) && + near(observed[1], 1'414) && + near(observed[2], 3'535) && + near(observed[3], 5'656) && + near(observed[4], 5'656) && + program.mode() == InjectorRuntimeSourceMode::ramp; +} + +bool finite_sequence_ends_in_hold() noexcept { + InjectorRuntimeProgram program(constant_profile(1'000)); + DeterministicSvInjector injector(program.segments(), 4'000U, true); + if (!injector.valid()) { + return false; + } + + const std::array states{ + InjectorSequenceState{2U, InjectorSegmentTransition::step, constant_profile(2'000)}, + InjectorSequenceState{2U, InjectorSegmentTransition::step, constant_profile(3'000)}}; + + InjectorSample sample{}; + if (!injector.step(sample) || + !program.stage_sequence(injector.segment_index(), states)) { + return false; + } + + std::array observed{}; + for (auto& value : observed) { + if (!injector.step(sample)) { + return false; + } + value = sample.values[0]; + } + + return near(observed[0], 1'414) && + near(observed[1], 2'828) && + near(observed[2], 2'828) && + near(observed[3], 4'242) && + near(observed[4], 4'242) && + near(observed[5], 4'242) && + near(observed[6], 4'242) && + program.mode() == InjectorRuntimeSourceMode::sequence; +} + +bool sequence_builder_is_transactional() noexcept { + const auto base = constant_profile(1'000); + InjectorSequenceBuilder builder; + builder.begin(base); + + if (!builder.begin_state(2U, InjectorSegmentTransition::step)) { + return false; + } + InjectorChannelEdit first{}; + first.channel_index = 0U; + first.fields = injector_field_rms; + first.rms_counts = 2'000; + if (!builder.edit_channel(first) || builder.ready() || + !builder.commit_state()) { + return false; + } + + if (!builder.begin_state(3U, InjectorSegmentTransition::linear_from_previous)) { + return false; + } + InjectorChannelEdit second{}; + second.channel_index = 0U; + second.fields = injector_field_rms; + second.rms_counts = 4'000; + if (!builder.edit_channel(second) || !builder.commit_state() || + !builder.ready() || builder.state_count() != 2U) { + return false; + } + + const auto states = builder.states(); + if (states.size() != 2U || + states[0].channels[0].rms_counts != 2'000 || + states[1].channels[0].rms_counts != 4'000 || + states[1].transition != InjectorSegmentTransition::linear_from_previous) { + return false; + } + + InjectorRuntimeProgram program(base); + DeterministicSvInjector injector(program.segments(), 4'000U, true); + InjectorSample sample{}; + if (!injector.step(sample) || + !program.stage_sequence(injector.segment_index(), states)) { + return false; + } + + builder.abort(); + return !builder.active() && !builder.ready() && + program.mode() == InjectorRuntimeSourceMode::sequence; +} + +bool profile_builder_is_atomic_and_revisioned() noexcept { + const auto base = make_balanced_4i4v_profile(); + InjectorProfileBuilder builder; + builder.begin(base, 17U); + if (!builder.active() || builder.ramp() || builder.source_revision() != 17U) { + return false; + } + + for (std::uint8_t channel = 0U; channel < 3U; ++channel) { + InjectorChannelEdit edit{}; + edit.channel_index = channel; + edit.fields = injector_field_rms; + edit.rms_counts = 3'000; + if (!builder.edit_channel(edit)) { + return false; + } + } + if (builder.target()[0].rms_counts != 3'000 || + builder.target()[1].rms_counts != 3'000 || + builder.target()[2].rms_counts != 3'000 || + base[0].rms_counts == 3'000) { + return false; + } + + builder.abort(); + if (builder.active()) { + return false; + } + + builder.begin(base, 18U, 4'000U); + if (!builder.active() || !builder.ramp() || + builder.ramp_duration_samples() != 4'000U || + builder.source_revision() != 18U) { + return false; + } + + builder.begin(base, 19U, 1U); + return !builder.active(); +} + +bool replay_bundle_and_ring_are_bounded() noexcept { + ReplayBundleHeader header{}; + header.sample_rate_hz = 4'000U; + header.frame_count = 2U; + + std::array encoded{}; + if (!encode_replay_bundle_header(header, encoded)) { + return false; + } + + ReplayBundleHeader decoded{}; + if (!decode_replay_bundle_header(encoded, decoded) || + decoded.version != 1U || decoded.sample_rate_hz != 4'000U || + decoded.frame_count != 2U || + replay_bundle_expected_bytes(decoded) != + replay_bundle_header_bytes + 2U * replay_payload_bytes) { + return false; + } + + InjectorReplayRing<2U> ring; + ReplayPayloadFrame first{}; + ReplayPayloadFrame second{}; + first[0] = 0x11U; + second[0] = 0x22U; + if (!ring.push(first) || !ring.push(second) || ring.push(first) || + ring.statistics().overflows != 1U) { + return false; + } + + ReplayPayloadFrame output{}; + if (!ring.pop(output) || output[0] != 0x11U || + !ring.pop(output) || output[0] != 0x22U || + ring.pop(output) || ring.statistics().underruns != 1U) { + return false; + } + + return ring.statistics().frames_pushed == 2U && + ring.statistics().frames_popped == 2U && ring.empty(); +} + +} // namespace + +int main() { + if (!hot_manual_update_preserves_timeline()) { + return 1; + } + if (!ramp_is_sample_indexed_and_holds_target()) { + return 2; + } + if (!finite_sequence_ends_in_hold()) { + return 3; + } + if (!sequence_builder_is_transactional()) { + return 4; + } + if (!profile_builder_is_atomic_and_revisioned()) { + return 5; + } + if (!replay_bundle_and_ring_are_bounded()) { + return 6; + } + return 0; +} diff --git a/embedded/sv_stream_pcap.cpp b/embedded/sv_stream_pcap.cpp new file mode 100644 index 000000000..5d6535ab4 --- /dev/null +++ b/embedded/sv_stream_pcap.cpp @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/embedded/io.hpp" +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/sampled_values/frame.hpp" +#include "ariec61850/sampled_values/payload_writer.hpp" +#include "ariec61850/sampled_values/publisher.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::uint32_t kSampleRateHz = 4'000U; +constexpr std::uint64_t kPublishIntervalUs = 250U; +constexpr std::uint16_t kSampleCountWrap = 4'000U; +constexpr std::uint64_t kCaptureFrames = 8'000U; +constexpr std::uint64_t kCaptureStartUs = 1'000'000U; +constexpr std::size_t kChannelCount = 8U; +constexpr std::size_t kPayloadBytes = + kChannelCount * sampled_values::SampledValuesPayloadWriter::int32_quality_pair_bytes; + +bool write_bytes( + std::ofstream& output, + const std::span bytes) noexcept { + output.write( + reinterpret_cast(bytes.data()), + static_cast(bytes.size())); + return static_cast(output); +} + +bool write_u16_le(std::ofstream& output, const std::uint16_t value) noexcept { + const std::array bytes{ + static_cast(value & 0xFFU), + static_cast((value >> 8U) & 0xFFU)}; + return write_bytes(output, bytes); +} + +bool write_u32_le(std::ofstream& output, const std::uint32_t value) noexcept { + const std::array bytes{ + static_cast(value & 0xFFU), + static_cast((value >> 8U) & 0xFFU), + static_cast((value >> 16U) & 0xFFU), + static_cast((value >> 24U) & 0xFFU)}; + return write_bytes(output, bytes); +} + +bool write_pcap_header(std::ofstream& output) noexcept { + // Classic PCAP, little-endian, microsecond timestamps, Ethernet link type. + return write_u32_le(output, 0xA1B2C3D4U) && + write_u16_le(output, 2U) && + write_u16_le(output, 4U) && + write_u32_le(output, 0U) && + write_u32_le(output, 0U) && + write_u32_le(output, 65'535U) && + write_u32_le(output, 1U); +} + +struct PcapEthernet final { + std::ofstream* output{}; + std::uint64_t frame_index{}; + bool failed{}; +}; + +embedded::IoResult capture_frame( + void* context, + const std::span frame) noexcept { + auto* capture = static_cast(context); + if (capture == nullptr || capture->output == nullptr || frame.empty() || + frame.size() > std::numeric_limits::max()) { + return {embedded::IoStatus::invalid_argument, 0U}; + } + + const auto timestamp_us = + kCaptureStartUs + capture->frame_index * kPublishIntervalUs; + const auto seconds = static_cast(timestamp_us / 1'000'000U); + const auto microseconds = static_cast(timestamp_us % 1'000'000U); + const auto frame_size = static_cast(frame.size()); + + auto& output = *capture->output; + const auto written = + write_u32_le(output, seconds) && + write_u32_le(output, microseconds) && + write_u32_le(output, frame_size) && + write_u32_le(output, frame_size) && + write_bytes(output, frame); + if (!written) { + capture->failed = true; + return {embedded::IoStatus::io_error, 0U}; + } + + ++capture->frame_index; + return {embedded::IoStatus::ok, frame.size()}; +} + +sampled_values::SampledValuesFrame make_frame() { + const std::array destination_mac{ + 0x01U, 0x0CU, 0xCDU, 0x04U, 0x00U, 0x01U}; + const std::array source_mac{ + 0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x02U}; + + sampled_values::SampledValueAsdu asdu; + asdu.sv_id = "ARSTACK61850_P4_TRIAL"; + asdu.data_set_reference = "ARSTACK61850/LLN0$PhsMeas1"; + asdu.configuration_revision = 1U; + asdu.sample_synchronization = 0U; + asdu.sample_rate = std::uint16_t{kSampleRateHz}; + asdu.sample_mode = std::uint16_t{1U}; + asdu.sample_payload.resize(kPayloadBytes, 0U); + + return { + ethernet::MacAddress{destination_mac}, + ethernet::MacAddress{source_mac}, + std::nullopt, + 0x4001U, + 0U, + 0U, + sampled_values::SampledValuesPdu{{asdu}}}; +} + +bool update_synthetic_payload( + sampled_values::SampledValueAsdu& asdu, + const std::uint16_t sample_count) noexcept { + if (asdu.sample_payload.size() != kPayloadBytes) { + return false; + } + + const auto payload = std::span{ + asdu.sample_payload.data(), asdu.sample_payload.size()}; + const auto count = static_cast(sample_count); + for (std::size_t channel = 0U; channel < kChannelCount; ++channel) { + const auto value = + count * 1'000 + static_cast(channel) * 100'000; + if (!sampled_values::SampledValuesPayloadWriter::write_int32_quality_pair( + payload, channel, value, 0U)) { + return false; + } + } + return true; +} + +} // namespace + +int main(const int argc, char** argv) { + const char* output_path = argc > 1 ? argv[1] : "sv_trial_2s.pcap"; + std::ofstream output(output_path, std::ios::binary | std::ios::trunc); + if (!output || !write_pcap_header(output)) { + return 1; + } + + PcapEthernet capture{&output}; + embedded::RawEthernetPort port{&capture, &capture_frame}; + auto frame = make_frame(); + std::array frame_buffer{}; + sampled_values::SampledValuesPublisher publisher( + frame, + frame_buffer, + port, + sampled_values::SampledValuesPublisherConfig{ + kSampleRateHz, + std::uint16_t{kSampleCountWrap}, + 0U, + true}); + + if (!publisher.valid()) { + return 2; + } + + for (std::uint64_t index = 0U; index < kCaptureFrames; ++index) { + const auto expected_count = static_cast(index % kSampleCountWrap); + if (publisher.next_sample_count() != expected_count || + !update_synthetic_payload(frame.pdu.asdus.front(), expected_count)) { + return 3; + } + + const auto now_us = kCaptureStartUs + index * kPublishIntervalUs; + const auto result = publisher.poll(now_us); + if (!result.sent() || result.sample_count != expected_count) { + return 4; + } + } + + output.flush(); + if (!output || capture.failed || capture.frame_index != kCaptureFrames) { + return 5; + } + + const auto& statistics = publisher.statistics(); + if (statistics.frames_sent != kCaptureFrames || + statistics.encode_failures != 0U || + statistics.transmit_failures != 0U || + statistics.late_polls != 0U || + statistics.maximum_lateness_us != 0U || + publisher.next_sample_count() != 0U) { + return 6; + } + + return 0; +} diff --git a/embedded/urcb_event_hard_profile/CMakeLists.txt b/embedded/urcb_event_hard_profile/CMakeLists.txt new file mode 100644 index 000000000..32dbe7d50 --- /dev/null +++ b/embedded/urcb_event_hard_profile/CMakeLists.txt @@ -0,0 +1,82 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARIEC61850UrcbEventHardProfile + VERSION 0.2.0 + DESCRIPTION "No-exception bounded IEC 61850 URCB event-report and connection profile" + LANGUAGES CXX) + +set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") + +if(MSVC) + message(FATAL_ERROR "URCB event hard profile is validated with GCC/Clang -fno-exceptions.") +endif() + +function(ariec61850_configure_urcb_event_target target) + target_compile_features(${target} PRIVATE cxx_std_20) + target_include_directories(${target} PRIVATE ${ARIEC61850_ROOT}/include) + target_compile_definitions(${target} PRIVATE + ARIEC61850_EMBEDDED_PROFILE=1 + ARIEC61850_HOST_SERVICES=0 + ARIEC61850_NO_EXCEPTIONS=1) + target_compile_options(${target} PRIVATE + -Wall + -Wextra + -Wpedantic + -Wconversion + -Wsign-conversion + -Werror + -fno-exceptions + -fno-rtti) +endfunction() + +add_executable(ariec61850_hard_mms_urcb_event_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_urcb_event_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp + ${ARIEC61850_ROOT}/src/mms/pdu_span.cpp + ${ARIEC61850_ROOT}/src/mms/services_span.cpp + ${ARIEC61850_ROOT}/src/mms/data_set_span.cpp + ${ARIEC61850_ROOT}/src/mms/information_report_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_object_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_data_set_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_information_report.cpp + ${ARIEC61850_ROOT}/src/mms/static_urcb_runtime.cpp + ${ARIEC61850_ROOT}/src/mms/selective_information_report_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_urcb_event_runtime.cpp) +ariec61850_configure_urcb_event_target(ariec61850_hard_mms_urcb_event_smoke) + +add_executable(ariec61850_hard_mms_urcb_event_connection_smoke + ${CMAKE_CURRENT_LIST_DIR}/../mms_urcb_event_connection_hard_profile_smoke.cpp + ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp + ${ARIEC61850_ROOT}/src/osi/tpkt_span.cpp + ${ARIEC61850_ROOT}/src/osi/cotp_span.cpp + ${ARIEC61850_ROOT}/src/osi/session_span.cpp + ${ARIEC61850_ROOT}/src/osi/presentation_span.cpp + ${ARIEC61850_ROOT}/src/acse/association_span.cpp + ${ARIEC61850_ROOT}/src/mms/pdu_span.cpp + ${ARIEC61850_ROOT}/src/mms/services_span.cpp + ${ARIEC61850_ROOT}/src/mms/write_span.cpp + ${ARIEC61850_ROOT}/src/mms/data_set_span.cpp + ${ARIEC61850_ROOT}/src/mms/information_report_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_object_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_write.cpp + ${ARIEC61850_ROOT}/src/mms/static_data_set_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_dispatcher.cpp + ${ARIEC61850_ROOT}/src/mms/static_information_report.cpp + ${ARIEC61850_ROOT}/src/mms/static_urcb_runtime.cpp + ${ARIEC61850_ROOT}/src/mms/connection_runtime.cpp + ${ARIEC61850_ROOT}/src/mms/selective_information_report_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_urcb_event_runtime.cpp + ${ARIEC61850_ROOT}/src/mms/static_urcb_event_connection.cpp) +ariec61850_configure_urcb_event_target(ariec61850_hard_mms_urcb_event_connection_smoke) + +enable_testing() +add_test( + NAME ariec61850_hard_mms_urcb_event_smoke + COMMAND ariec61850_hard_mms_urcb_event_smoke) +add_test( + NAME ariec61850_hard_mms_urcb_event_connection_smoke + COMMAND ariec61850_hard_mms_urcb_event_connection_smoke) + +message(STATUS "arstack61850 URCB event hard profile: dchg/qchg/dupd + BufTm=ON") +message(STATUS "arstack61850 URCB event hard profile: retry-safe connection transport=ON") +message(STATUS "arstack61850 URCB event hard profile: exceptions/RTTI disabled=ON") diff --git a/include/ariec61850/acse/association_span.hpp b/include/ariec61850/acse/association_span.hpp new file mode 100644 index 000000000..b18d1d7ea --- /dev/null +++ b/include/ariec61850/acse/association_span.hpp @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/osi/presentation_span.hpp" +#include "ariec61850/osi/session_span.hpp" +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::acse { + +struct AcseExternalView final { + std::span direct_reference{}; + std::uint32_t indirect_reference{}; + std::span single_asn1_type{}; +}; + +struct AcseAarqView final { + std::span application_context_name{}; + std::span called_ap_title{}; + std::optional called_ae_qualifier{}; + std::span calling_ap_title{}; + std::optional calling_ae_qualifier{}; + AcseExternalView user_information{}; +}; + +struct AssociationRequestView final { + osi::SessionSpduView session{}; + osi::PresentationCpView presentation{}; + AcseAarqView aarq{}; + std::uint32_t acse_presentation_context_id{}; + std::uint32_t mms_presentation_context_id{}; +}; + +class AcseSpanCodec final { +public: + static constexpr std::size_t maximum_acse_bytes = 1U * 1024U * 1024U; + static constexpr std::size_t maximum_oid_bytes = 64U; + + [[nodiscard]] static std::span + mms_application_context_name() noexcept; + [[nodiscard]] static std::span + default_mms_initiate_request() noexcept; + [[nodiscard]] static std::span + default_mms_initiate_response() noexcept; + + [[nodiscard]] static bool try_decode_aarq_view( + std::span bytes, + AcseAarqView& aarq) noexcept; + + [[nodiscard]] static bool try_decode_association_request_view( + std::span bytes, + AssociationRequestView& request) noexcept; + + [[nodiscard]] static constexpr std::size_t default_accept_aare_size() noexcept { + return 76U; + } + + [[nodiscard]] static wire::EncodeResult encode_default_accept_aare_into( + std::span destination) noexcept; + + [[nodiscard]] static std::optional accept_response_size( + const AssociationRequestView& request) noexcept; + + [[nodiscard]] static wire::EncodeResult build_accept_response_into( + const AssociationRequestView& request, + std::span destination) noexcept; +}; + +} // namespace ar::iec61850::acse diff --git a/include/ariec61850/asn1/ber.hpp b/include/ariec61850/asn1/ber.hpp index 9cc816fa2..a8fd07d56 100644 --- a/include/ariec61850/asn1/ber.hpp +++ b/include/ariec61850/asn1/ber.hpp @@ -2,6 +2,8 @@ #pragma once +#include "ariec61850/asn1/ber_types.hpp" + #include #include #include @@ -13,13 +15,6 @@ namespace ar::iec61850::asn1 { -enum class BerClass : std::uint8_t { - universal = 0, - application = 1, - context_specific = 2, - private_class = 3 -}; - class BerFormatError final : public std::runtime_error { public: using std::runtime_error::runtime_error; diff --git a/include/ariec61850/asn1/ber_span_reader.hpp b/include/ariec61850/asn1/ber_span_reader.hpp new file mode 100644 index 000000000..82b58965b --- /dev/null +++ b/include/ariec61850/asn1/ber_span_reader.hpp @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/asn1/ber_types.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::asn1 { + +struct BerTlvView final { + std::uint8_t encoded_tag{}; + BerClass tag_class{BerClass::universal}; + bool constructed{}; + std::int32_t tag_number{}; + std::size_t header_bytes{}; + std::size_t encoded_bytes{}; + std::span value{}; +}; + +class BerSpanReader final { +public: + // Read one definite-length BER TLV without allocating or copying its value. + // The input offset is advanced only on success. + [[nodiscard]] static bool try_read_tlv( + std::span source, + std::size_t& offset, + BerTlvView& tlv) noexcept; + + [[nodiscard]] static bool try_read_exact( + std::span source, + BerTlvView& tlv) noexcept; + + [[nodiscard]] static std::optional read_unsigned_integer( + const BerTlvView& tlv) noexcept; + + [[nodiscard]] static std::optional read_signed_integer( + const BerTlvView& tlv) noexcept; + + [[nodiscard]] static std::optional read_uint32( + const BerTlvView& tlv) noexcept; +}; + +} // namespace ar::iec61850::asn1 diff --git a/include/ariec61850/asn1/ber_span_writer.hpp b/include/ariec61850/asn1/ber_span_writer.hpp new file mode 100644 index 000000000..f169ea7e5 --- /dev/null +++ b/include/ariec61850/asn1/ber_span_writer.hpp @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/asn1/ber_types.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::asn1 { + +class BerSpanWriter final { +public: + explicit BerSpanWriter(std::span destination) noexcept + : destination_{destination} {} + + [[nodiscard]] static std::optional identifier_octets( + const std::int32_t tag_number) noexcept { + if (tag_number < 0) { + return std::nullopt; + } + if (tag_number <= 30) { + return 1U; + } + + auto remaining = static_cast(tag_number); + std::size_t groups = 0U; + do { + ++groups; + remaining >>= 7U; + } while (remaining != 0U); + return 1U + groups; + } + + [[nodiscard]] static std::optional length_octets( + const std::size_t value_length) noexcept { + if (value_length < 0x80U) { + return 1U; + } + if (value_length > std::numeric_limits::max()) { + return std::nullopt; + } + + auto value = static_cast(value_length); + std::size_t bytes = 0U; + while (value != 0U) { + ++bytes; + value >>= 8U; + } + return 1U + bytes; + } + + [[nodiscard]] static std::optional tlv_size( + const std::int32_t tag_number, + const std::size_t value_length) noexcept { + const auto identifier = identifier_octets(tag_number); + const auto length = length_octets(value_length); + if (!identifier || !length) { + return std::nullopt; + } + if (*length > std::numeric_limits::max() - *identifier) { + return std::nullopt; + } + const auto header = *identifier + *length; + if (value_length > std::numeric_limits::max() - header) { + return std::nullopt; + } + return header + value_length; + } + + [[nodiscard]] bool write_identifier( + const BerClass tag_class, + const bool constructed, + const std::int32_t tag_number) noexcept { + if (tag_number < 0) { + failed_ = true; + return false; + } + + const auto class_bits = static_cast( + static_cast(tag_class) << 6U); + const auto constructed_bit = constructed ? std::uint8_t{0x20U} : std::uint8_t{0U}; + if (tag_number <= 30) { + return write_byte(static_cast( + class_bits | constructed_bit | static_cast(tag_number))); + } + + if (!write_byte(static_cast(class_bits | constructed_bit | 0x1FU))) { + return false; + } + + std::array groups{}; + std::size_t count = 0U; + auto remaining = static_cast(tag_number); + do { + if (count >= groups.size()) { + failed_ = true; + return false; + } + groups[count++] = static_cast(remaining & 0x7FU); + remaining >>= 7U; + } while (remaining != 0U); + + for (std::size_t index = count; index-- > 0U;) { + auto byte = groups[index]; + if (index != 0U) { + byte = static_cast(byte | 0x80U); + } + if (!write_byte(byte)) { + return false; + } + } + return true; + } + + [[nodiscard]] bool write_length(const std::size_t value_length) noexcept { + if (value_length < 0x80U) { + return write_byte(static_cast(value_length)); + } + if (value_length > std::numeric_limits::max()) { + failed_ = true; + return false; + } + + std::array bytes{}; + auto value = static_cast(value_length); + std::size_t count = 0U; + while (value != 0U) { + bytes[bytes.size() - 1U - count] = static_cast(value & 0xFFU); + value >>= 8U; + ++count; + } + if (!write_byte(static_cast(0x80U | count))) { + return false; + } + return write_bytes(std::span{bytes}.last(count)); + } + + [[nodiscard]] bool write_tlv_header( + const BerClass tag_class, + const bool constructed, + const std::int32_t tag_number, + const std::size_t value_length) noexcept { + return write_identifier(tag_class, constructed, tag_number) && + write_length(value_length); + } + + [[nodiscard]] bool write_tlv( + const BerClass tag_class, + const bool constructed, + const std::int32_t tag_number, + const std::span value) noexcept { + return write_tlv_header(tag_class, constructed, tag_number, value.size()) && + write_bytes(value); + } + + [[nodiscard]] bool write_bytes(const std::span value) noexcept { + if (failed_ || value.size() > remaining()) { + failed_ = true; + return false; + } + std::copy(value.begin(), value.end(), destination_.begin() + + static_cast(offset_)); + offset_ += value.size(); + return true; + } + + [[nodiscard]] bool write_byte(const std::uint8_t value) noexcept { + if (failed_ || offset_ >= destination_.size()) { + failed_ = true; + return false; + } + destination_[offset_++] = value; + return true; + } + + [[nodiscard]] std::size_t size() const noexcept { return offset_; } + [[nodiscard]] std::size_t remaining() const noexcept { + return offset_ <= destination_.size() ? destination_.size() - offset_ : 0U; + } + [[nodiscard]] bool good() const noexcept { return !failed_; } + +private: + std::span destination_; + std::size_t offset_{}; + bool failed_{}; +}; + +} // namespace ar::iec61850::asn1 diff --git a/include/ariec61850/asn1/ber_types.hpp b/include/ariec61850/asn1/ber_types.hpp new file mode 100644 index 000000000..b4b3a5c4a --- /dev/null +++ b/include/ariec61850/asn1/ber_types.hpp @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include + +namespace ar::iec61850::asn1 { + +enum class BerClass : std::uint8_t { + universal = 0, + application = 1, + context_specific = 2, + private_class = 3 +}; + +} // namespace ar::iec61850::asn1 diff --git a/include/ariec61850/ethernet/ethernet.hpp b/include/ariec61850/ethernet/ethernet.hpp index 1d23b5ed2..363191086 100644 --- a/include/ariec61850/ethernet/ethernet.hpp +++ b/include/ariec61850/ethernet/ethernet.hpp @@ -2,7 +2,10 @@ #pragma once +#include "ariec61850/wire/encode_result.hpp" + #include +#include #include #include #include @@ -19,14 +22,32 @@ inline constexpr std::uint16_t ptp_ethertype = 0x88F7; class MacAddress final { public: MacAddress() = default; + + // Exact-size construction is the preferred embedded path: the size is + // encoded in the type, so there is nothing to validate or throw at runtime. + explicit constexpr MacAddress(const std::array& bytes) noexcept + : bytes_(bytes) {} + + [[nodiscard]] static bool try_from_bytes( + std::span bytes, + MacAddress& address) noexcept; + +#if !defined(ARIEC61850_NO_EXCEPTIONS) + // Host/source compatibility wrapper when the caller only has a span. explicit MacAddress(std::span bytes); +#endif - [[nodiscard]] static MacAddress parse(const std::string& text); [[nodiscard]] static bool try_parse(const std::string& text, MacAddress& address) noexcept; +#if !defined(ARIEC61850_NO_EXCEPTIONS) + [[nodiscard]] static MacAddress parse(const std::string& text); +#endif [[nodiscard]] const std::array& bytes() const noexcept { return bytes_; } + [[nodiscard]] bool try_copy_to(std::span destination) const noexcept; +#if !defined(ARIEC61850_NO_EXCEPTIONS) void copy_to(std::span destination) const; [[nodiscard]] std::string to_string() const; +#endif friend bool operator==(const MacAddress&, const MacAddress&) = default; @@ -45,7 +66,10 @@ struct VlanTag final { VlanTag(std::uint8_t priority, bool drop, std::uint16_t vlan) : priority_code_point(priority), drop_eligible(drop), vlan_id(vlan) {} + [[nodiscard]] bool try_to_tag_control_information(std::uint16_t& tci) const noexcept; +#if !defined(ARIEC61850_NO_EXCEPTIONS) [[nodiscard]] std::uint16_t to_tag_control_information() const; +#endif [[nodiscard]] static VlanTag from_tag_control_information(std::uint16_t tci) noexcept; friend bool operator==(const VlanTag&, const VlanTag&) = default; @@ -61,8 +85,18 @@ struct EthernetFrame final { class EthernetFrameCodec final { public: + [[nodiscard]] static std::optional encoded_size( + const EthernetFrame& frame) noexcept; + [[nodiscard]] static wire::EncodeResult encode_into( + const EthernetFrame& frame, + std::span destination) noexcept; + +#if !defined(ARIEC61850_NO_EXCEPTIONS) [[nodiscard]] static std::vector encode(const EthernetFrame& frame); - [[nodiscard]] static bool try_decode(std::span bytes, EthernetFrame& frame) noexcept; + [[nodiscard]] static bool try_decode( + std::span bytes, + EthernetFrame& frame) noexcept; +#endif }; struct ProcessBusFrame final { @@ -78,6 +112,16 @@ class ProcessBusFrameCodec final { public: static constexpr std::size_t header_length = 8U; + [[nodiscard]] static std::optional encoded_payload_size( + std::span apdu) noexcept; + [[nodiscard]] static wire::EncodeResult encode_payload_into( + std::uint16_t app_id, + std::span apdu, + std::span destination, + std::uint16_t reserved1 = 0, + std::uint16_t reserved2 = 0) noexcept; + +#if !defined(ARIEC61850_NO_EXCEPTIONS) [[nodiscard]] static std::vector encode_payload( std::uint16_t app_id, std::span apdu, @@ -94,7 +138,10 @@ class ProcessBusFrameCodec final { std::uint16_t reserved1 = 0, std::uint16_t reserved2 = 0); - [[nodiscard]] static bool try_decode(const EthernetFrame& ethernet, ProcessBusFrame& frame) noexcept; + [[nodiscard]] static bool try_decode( + const EthernetFrame& ethernet, + ProcessBusFrame& frame) noexcept; +#endif }; } // namespace ar::iec61850::ethernet diff --git a/include/ariec61850/goose/frame_codec.hpp b/include/ariec61850/goose/frame_codec.hpp index 473f3cfdb..158e86df5 100644 --- a/include/ariec61850/goose/frame_codec.hpp +++ b/include/ariec61850/goose/frame_codec.hpp @@ -3,8 +3,11 @@ #pragma once #include "ariec61850/goose/frame.hpp" +#include "ariec61850/wire/encode_result.hpp" +#include #include +#include #include #include @@ -12,9 +15,17 @@ namespace ar::iec61850::goose { class GooseFrameCodec final { public: + [[nodiscard]] static std::optional encoded_size( + const GooseFrame& frame) noexcept; + [[nodiscard]] static wire::EncodeResult encode_into( + const GooseFrame& frame, + std::span destination) noexcept; + +#if !defined(ARIEC61850_NO_EXCEPTIONS) [[nodiscard]] static std::vector encode(const GooseFrame& frame); [[nodiscard]] static bool try_decode( std::span frame_bytes, GooseFrame& frame) noexcept; +#endif }; } // namespace ar::iec61850::goose diff --git a/include/ariec61850/goose/pdu_codec.hpp b/include/ariec61850/goose/pdu_codec.hpp index 724bd38b5..80c18304d 100644 --- a/include/ariec61850/goose/pdu_codec.hpp +++ b/include/ariec61850/goose/pdu_codec.hpp @@ -3,8 +3,11 @@ #pragma once #include "ariec61850/goose/pdu.hpp" +#include "ariec61850/wire/encode_result.hpp" +#include #include +#include #include #include @@ -12,9 +15,19 @@ namespace ar::iec61850::goose { class GoosePduCodec final { public: + // Allocation-free transmit path for embedded/steady-state publishers. + [[nodiscard]] static std::optional encoded_size( + const GoosePdu& pdu) noexcept; + [[nodiscard]] static wire::EncodeResult encode_into( + const GoosePdu& pdu, + std::span destination) noexcept; + +#if !defined(ARIEC61850_NO_EXCEPTIONS) + // Host convenience/decode surface retained for compatibility. [[nodiscard]] static std::vector encode(const GoosePdu& pdu); [[nodiscard]] static bool try_decode( std::span apdu, GoosePdu& pdu) noexcept; +#endif }; } // namespace ar::iec61850::goose diff --git a/include/ariec61850/mms/buffered_selective_information_report_span.hpp b/include/ariec61850/mms/buffered_selective_information_report_span.hpp new file mode 100644 index 000000000..cbbdcfbfd --- /dev/null +++ b/include/ariec61850/mms/buffered_selective_information_report_span.hpp @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/information_report_span.hpp" +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +// Selective InformationReport form for buffered reports. EntryID and BufOvfl +// are encoded only when selected by OptFlds. The DataSet inclusion bit-string +// keeps the original DataSet cardinality while values/reasons are emitted only +// for selected members. +struct MmsBufferedSelectiveInformationReportSnapshotInput final { + std::string_view report_id; + std::span optional_fields; + std::uint32_t sequence_number{}; + std::span report_time; + MmsInformationReportReferenceInput data_set_reference{}; + bool buffer_overflow{}; + std::span entry_id; + std::uint32_t conf_revision{}; + + std::size_t data_set_member_count{}; + std::span included_member_indices; + std::span included_member_references; + std::span included_member_results; + std::span included_reason_for_inclusion; +}; + +class MmsBufferedSelectiveInformationReportSpanCodec final { +public: + [[nodiscard]] static wire::EncodeResult encode_snapshot_into( + const MmsBufferedSelectiveInformationReportSnapshotInput& report, + std::span destination) noexcept; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/buffered_selective_report_detail.hpp b/include/ariec61850/mms/buffered_selective_report_detail.hpp new file mode 100644 index 000000000..774b2ab62 --- /dev/null +++ b/include/ariec61850/mms/buffered_selective_report_detail.hpp @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/asn1/ber_span_writer.hpp" +#include "ariec61850/mms/buffered_selective_information_report_span.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms::detail { + +inline constexpr std::uint8_t opt_sequence_number = 0x40U; +inline constexpr std::uint8_t opt_report_timestamp = 0x20U; +inline constexpr std::uint8_t opt_reason_for_inclusion = 0x10U; +inline constexpr std::uint8_t opt_data_set_name = 0x08U; +inline constexpr std::uint8_t opt_data_reference = 0x04U; +inline constexpr std::uint8_t opt_buffer_overflow = 0x02U; +inline constexpr std::uint8_t opt_entry_id = 0x01U; +inline constexpr std::uint8_t opt_conf_revision = 0x80U; + +[[nodiscard]] inline bool optional_selected( + const MmsBufferedSelectiveInformationReportSnapshotInput& report, + const std::size_t byte_index, + const std::uint8_t mask) noexcept { + return report.optional_fields.size() > byte_index && + (report.optional_fields[byte_index] & mask) != 0U; +} + +[[nodiscard]] inline bool visible_ascii( + const std::string_view text, + const std::size_t maximum_bytes) noexcept { + if (text.empty() || text.size() > maximum_bytes) return false; + for (const auto character : text) { + const auto byte = static_cast(character); + if (byte == 0U || byte > 0x7FU) return false; + } + return true; +} + +[[nodiscard]] inline std::size_t minimal_unsigned_size(std::uint32_t value) noexcept { + std::size_t bytes = 1U; + while (value > 0xFFU) { + ++bytes; + value >>= 8U; + } + return bytes; +} + +[[nodiscard]] inline bool needs_positive_prefix( + const std::uint32_t value, + const std::size_t minimal_bytes) noexcept { + const auto shift = static_cast((minimal_bytes - 1U) * 8U); + return ((value >> shift) & 0x80U) != 0U; +} + +[[nodiscard]] inline std::size_t positive_integer_size(const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + return minimal + (needs_positive_prefix(value, minimal) ? 1U : 0U); +} + +[[nodiscard]] inline bool write_unsigned_content( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto bytes = minimal_unsigned_size(value); + for (std::size_t index = bytes; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte(static_cast((value >> shift) & 0xFFU))) { + return false; + } + } + return true; +} + +[[nodiscard]] inline bool write_positive_content( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto bytes = minimal_unsigned_size(value); + if (needs_positive_prefix(value, bytes) && !writer.write_byte(0x00U)) return false; + return write_unsigned_content(writer, value); +} + +[[nodiscard]] inline bool valid_mms_data(const std::span encoded) noexcept { + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(encoded, tlv) || + tlv.tag_class != asn1::BerClass::context_specific) return false; + if (tlv.tag_number == 1 || tlv.tag_number == 2) return tlv.constructed; + return tlv.tag_number >= 3 && tlv.tag_number <= 17 && !tlv.constructed; +} + +[[nodiscard]] inline std::optional access_result_size( + const MmsReadAccessResultInput& result) noexcept { + if (result.success) { + return valid_mms_data(result.encoded_data) + ? std::optional{result.encoded_data.size()} + : std::nullopt; + } + return asn1::BerSpanWriter::tlv_size(0, positive_integer_size(result.failure_code)); +} + +[[nodiscard]] inline bool write_access_result( + asn1::BerSpanWriter& writer, + const MmsReadAccessResultInput& result) noexcept { + if (result.success) return writer.write_bytes(result.encoded_data); + const auto content = positive_integer_size(result.failure_code); + return writer.write_tlv_header(asn1::BerClass::context_specific, false, 0, content) && + write_positive_content(writer, result.failure_code); +} + +[[nodiscard]] inline std::optional reference_content_size( + const MmsInformationReportReferenceInput& reference) noexcept { + if (!visible_ascii(reference.domain, MmsServiceSpanCodec::maximum_identifier_bytes) || + !visible_ascii(reference.item, MmsServiceSpanCodec::maximum_identifier_bytes)) { + return std::nullopt; + } + if (reference.item.size() > MmsInformationReportSpanCodec::maximum_reference_bytes - 1U || + reference.domain.size() > + MmsInformationReportSpanCodec::maximum_reference_bytes - 1U - reference.item.size()) { + return std::nullopt; + } + return reference.domain.size() + 1U + reference.item.size(); +} + +[[nodiscard]] inline bool write_reference( + asn1::BerSpanWriter& writer, + const MmsInformationReportReferenceInput& reference) noexcept { + const auto content = reference_content_size(reference); + return content && + writer.write_tlv_header(asn1::BerClass::context_specific, false, 10, *content) && + writer.write_bytes(std::span{ + reinterpret_cast(reference.domain.data()), + reference.domain.size()}) && + writer.write_byte(static_cast('/')) && + writer.write_bytes(std::span{ + reinterpret_cast(reference.item.data()), + reference.item.size()}); +} + +[[nodiscard]] inline bool write_visible( + asn1::BerSpanWriter& writer, + const std::string_view text) noexcept { + return writer.write_tlv_header(asn1::BerClass::context_specific, false, 10, text.size()) && + writer.write_bytes(std::span{ + reinterpret_cast(text.data()), text.size()}); +} + +[[nodiscard]] inline bool write_bit_string( + asn1::BerSpanWriter& writer, + const std::uint8_t unused_bits, + const std::span bytes) noexcept { + return unused_bits <= 7U && + writer.write_tlv_header(asn1::BerClass::context_specific, false, 4, 1U + bytes.size()) && + writer.write_byte(unused_bits) && writer.write_bytes(bytes); +} + +[[nodiscard]] inline bool write_unsigned( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto content = minimal_unsigned_size(value); + return writer.write_tlv_header(asn1::BerClass::context_specific, false, 6, content) && + write_unsigned_content(writer, value); +} + +[[nodiscard]] inline bool write_boolean( + asn1::BerSpanWriter& writer, + const bool value) noexcept { + return writer.write_tlv_header(asn1::BerClass::context_specific, false, 3, 1U) && + writer.write_byte(value ? 0x01U : 0x00U); +} + +[[nodiscard]] inline bool add_to( + std::size_t& total, + const std::optional value) noexcept { + if (!value || *value > std::numeric_limits::max() - total) return false; + total += *value; + return true; +} + +} // namespace ar::iec61850::mms::detail diff --git a/include/ariec61850/mms/connection_runtime.hpp b/include/ariec61850/mms/connection_runtime.hpp new file mode 100644 index 000000000..8cb2ddbc8 --- /dev/null +++ b/include/ariec61850/mms/connection_runtime.hpp @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/static_dispatcher.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsStaticConnectionState : std::uint8_t { + awaiting_cotp_connect, + awaiting_association, + established, + closed, + fault, +}; + +enum class MmsStaticConnectionStatus : std::uint8_t { + need_more, + response_ready, + consumed_no_response, + malformed_transport, + protocol_violation, + application_rejected, + response_buffer_too_small, + workspace_too_small, + backend_failure, + closed, +}; + +struct MmsStaticConnectionPolicy final { + std::uint16_t cotp_source_reference{0x1001U}; + std::uint8_t maximum_tpdu_size_code{0x0AU}; + bool require_end_of_transmission{true}; +}; + +struct MmsStaticConnectionResult final { + MmsStaticConnectionStatus status{MmsStaticConnectionStatus::need_more}; + MmsStaticConnectionState state{MmsStaticConnectionState::awaiting_cotp_connect}; + std::size_t consumed_bytes{}; + std::size_t bytes_written{}; + std::size_t required_response_bytes{}; + std::size_t required_workspace_bytes{}; + MmsStaticDispatchStatus application_status{MmsStaticDispatchStatus::malformed_request}; + MmsWireConfirmedService application_service{MmsWireConfirmedService::unknown}; + std::uint32_t invoke_id{}; + + [[nodiscard]] constexpr bool response_ready() const noexcept { + return status == MmsStaticConnectionStatus::response_ready; + } +}; + +class MmsStaticConnectionRuntime final { +public: + explicit constexpr MmsStaticConnectionRuntime( + const MmsStaticApplicationDispatcher& dispatcher, + const MmsStaticConnectionPolicy policy = {}) noexcept + : dispatcher_{dispatcher}, policy_{policy} {} + + // Process at most the first complete TPKT frame in a TCP receive window. + // `consumed_bytes` identifies the prefix the transport adapter may remove. + // On need_more or buffer-capacity failures no input is consumed, allowing + // the same frame to be retried once more bytes/capacity are available. + [[nodiscard]] MmsStaticConnectionResult process_tcp_window( + std::span tcp_bytes, + std::span response, + std::span workspace) noexcept; + + constexpr void reset() noexcept { + state_ = MmsStaticConnectionState::awaiting_cotp_connect; + mms_presentation_context_id_ = 0U; + } + + [[nodiscard]] constexpr MmsStaticConnectionState state() const noexcept { + return state_; + } + + [[nodiscard]] constexpr std::uint32_t mms_presentation_context_id() const noexcept { + return mms_presentation_context_id_; + } + +private: + const MmsStaticApplicationDispatcher& dispatcher_; + MmsStaticConnectionPolicy policy_{}; + MmsStaticConnectionState state_{MmsStaticConnectionState::awaiting_cotp_connect}; + std::uint32_t mms_presentation_context_id_{}; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/data_set_span.hpp b/include/ariec61850/mms/data_set_span.hpp new file mode 100644 index 000000000..fd40adbf9 --- /dev/null +++ b/include/ariec61850/mms/data_set_span.hpp @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/services_span.hpp" +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +struct MmsNamedVariableListAttributesRequestView final { + std::uint32_t invoke_id{}; + MmsObjectNameView name{}; +}; + +struct MmsNamedVariableListAttributesResponseView final { + std::uint32_t invoke_id{}; + bool mms_deletable{}; + std::span variable_list{}; + std::size_t member_count{}; + + [[nodiscard]] bool try_member( + std::size_t index, + MmsObjectNameView& name) const noexcept; +}; + +struct MmsNamedVariableListMemberInput final { + std::string_view domain; + std::string_view item; +}; + +class MmsDataSetSpanCodec final { +public: + static constexpr std::size_t maximum_members = MmsServiceSpanCodec::maximum_variables; + + [[nodiscard]] static bool try_decode_get_named_variable_list_attributes_request( + const MmsConfirmedPduView& confirmed, + MmsNamedVariableListAttributesRequestView& request) noexcept; + + [[nodiscard]] static bool try_decode_get_named_variable_list_attributes_request( + std::span mms_pdu, + MmsNamedVariableListAttributesRequestView& request) noexcept; + + [[nodiscard]] static bool try_decode_get_named_variable_list_attributes_response( + const MmsConfirmedPduView& confirmed, + MmsNamedVariableListAttributesResponseView& response) noexcept; + + [[nodiscard]] static bool try_decode_get_named_variable_list_attributes_response( + std::span mms_pdu, + MmsNamedVariableListAttributesResponseView& response) noexcept; + + [[nodiscard]] static wire::EncodeResult + encode_get_named_variable_list_attributes_response_into( + std::uint32_t invoke_id, + bool mms_deletable, + std::span members, + std::span destination) noexcept; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/data_span_codec.hpp b/include/ariec61850/mms/data_span_codec.hpp new file mode 100644 index 000000000..39c13690a --- /dev/null +++ b/include/ariec61850/mms/data_span_codec.hpp @@ -0,0 +1,340 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/asn1/ber_span_writer.hpp" +#include "ariec61850/mms/data_value.hpp" +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +class MmsDataSpanCodec final { +public: + static constexpr std::size_t maximum_nesting_depth = 16U; + + [[nodiscard]] static std::optional encoded_size( + const MmsDataValue& value) noexcept { + return encoded_size_impl(value, 0U); + } + + [[nodiscard]] static std::optional encoded_all_size( + const std::span values) noexcept { + return encoded_all_size_impl(values, 0U); + } + + [[nodiscard]] static wire::EncodeResult encode_into( + const MmsDataValue& value, + const std::span destination) noexcept { + const auto required = encoded_size(value); + if (!required) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + asn1::BerSpanWriter writer{destination.first(*required)}; + if (!write_value(writer, value, 0U) || writer.size() != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, writer.size(), *required}; + } + + [[nodiscard]] static wire::EncodeResult encode_all_into( + const std::span values, + const std::span destination) noexcept { + const auto required = encoded_all_size(values); + if (!required) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + asn1::BerSpanWriter writer{destination.first(*required)}; + for (const auto& value : values) { + if (!write_value(writer, value, 0U)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + } + if (writer.size() != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, writer.size(), *required}; + } + +private: + [[nodiscard]] static std::optional tag_for( + const MmsDataValue& value) noexcept { + switch (value.kind()) { + case MmsDataKind::array: return 1; + case MmsDataKind::structure: return 2; + case MmsDataKind::boolean: return 3; + case MmsDataKind::bit_string: return 4; + case MmsDataKind::integer: return 5; + case MmsDataKind::unsigned_integer: return 6; + case MmsDataKind::floating_point: return 7; + case MmsDataKind::octet_string: return 9; + case MmsDataKind::visible_string: return 10; + case MmsDataKind::binary_time: return 12; + case MmsDataKind::mms_string: return 16; + case MmsDataKind::utc_time: return 17; + case MmsDataKind::unknown: + return value.unknown_tag_number(); + default: + return std::nullopt; + } + } + + [[nodiscard]] static std::size_t unsigned_integer_size( + std::uint64_t value) noexcept { + std::size_t size = 1U; + while (value > 0xFFU) { + ++size; + value >>= 8U; + } + return size; + } + + [[nodiscard]] static std::size_t signed_integer_size( + const std::int64_t value) noexcept { + const auto raw = std::bit_cast(value); + std::array bytes{}; + for (std::size_t index = 0U; index < bytes.size(); ++index) { + const auto shift = static_cast((bytes.size() - 1U - index) * 8U); + bytes[index] = static_cast((raw >> shift) & 0xFFU); + } + + std::size_t first = 0U; + while (first < bytes.size() - 1U) { + const auto current = bytes[first]; + const auto next = bytes[first + 1U]; + const bool redundant_positive = current == 0x00U && (next & 0x80U) == 0U; + const bool redundant_negative = current == 0xFFU && (next & 0x80U) != 0U; + if (!redundant_positive && !redundant_negative) { + break; + } + ++first; + } + return bytes.size() - first; + } + + [[nodiscard]] static std::optional content_size( + const MmsDataValue& value, + const std::size_t depth) noexcept { + if (depth > maximum_nesting_depth) { + return std::nullopt; + } + + switch (value.kind()) { + case MmsDataKind::array: + case MmsDataKind::structure: + return encoded_all_size_impl(value.children(), depth + 1U); + case MmsDataKind::boolean: + return std::get_if(&value.value()) != nullptr + ? std::optional{1U} + : std::nullopt; + case MmsDataKind::bit_string: + case MmsDataKind::octet_string: + case MmsDataKind::binary_time: + return value.raw_value().size(); + case MmsDataKind::integer: { + const auto* scalar = std::get_if(&value.value()); + return scalar != nullptr + ? std::optional{signed_integer_size(*scalar)} + : std::nullopt; + } + case MmsDataKind::unsigned_integer: { + const auto* scalar = std::get_if(&value.value()); + return scalar != nullptr + ? std::optional{unsigned_integer_size(*scalar)} + : std::nullopt; + } + case MmsDataKind::floating_point: + return (std::get_if(&value.value()) != nullptr || + std::get_if(&value.value()) != nullptr) + ? std::optional{5U} + : std::nullopt; + case MmsDataKind::visible_string: + case MmsDataKind::mms_string: { + const auto* text = std::get_if(&value.value()); + return text != nullptr + ? std::optional{text->size()} + : std::nullopt; + } + case MmsDataKind::utc_time: { + const auto* utc = std::get_if(&value.value()); + if (utc == nullptr) { + return std::nullopt; + } + std::array bytes{}; + return utc->try_write_bytes(bytes) + ? std::optional{bytes.size()} + : std::nullopt; + } + case MmsDataKind::unknown: + return value.unknown_tag_number().has_value() + ? std::optional{value.raw_value().size()} + : std::nullopt; + default: + return std::nullopt; + } + } + + [[nodiscard]] static std::optional encoded_size_impl( + const MmsDataValue& value, + const std::size_t depth) noexcept { + if (depth > maximum_nesting_depth) { + return std::nullopt; + } + const auto tag = tag_for(value); + const auto content = content_size(value, depth); + if (!tag || !content) { + return std::nullopt; + } + return asn1::BerSpanWriter::tlv_size(*tag, *content); + } + + [[nodiscard]] static std::optional encoded_all_size_impl( + const std::span values, + const std::size_t depth) noexcept { + if (depth > maximum_nesting_depth) { + return std::nullopt; + } + std::size_t total = 0U; + for (const auto& value : values) { + const auto size = encoded_size_impl(value, depth); + if (!size || *size > std::numeric_limits::max() - total) { + return std::nullopt; + } + total += *size; + } + return total; + } + + [[nodiscard]] static bool write_unsigned( + asn1::BerSpanWriter& writer, + const std::uint64_t value) noexcept { + const auto size = unsigned_integer_size(value); + for (std::size_t index = size; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte(static_cast((value >> shift) & 0xFFU))) { + return false; + } + } + return true; + } + + [[nodiscard]] static bool write_signed( + asn1::BerSpanWriter& writer, + const std::int64_t value) noexcept { + const auto raw = std::bit_cast(value); + std::array bytes{}; + for (std::size_t index = 0U; index < bytes.size(); ++index) { + const auto shift = static_cast((bytes.size() - 1U - index) * 8U); + bytes[index] = static_cast((raw >> shift) & 0xFFU); + } + const auto size = signed_integer_size(value); + return writer.write_bytes(std::span{bytes}.last(size)); + } + + [[nodiscard]] static bool write_value( + asn1::BerSpanWriter& writer, + const MmsDataValue& value, + const std::size_t depth) noexcept { + if (depth > maximum_nesting_depth) { + return false; + } + const auto tag = tag_for(value); + const auto content = content_size(value, depth); + if (!tag || !content) { + return false; + } + const bool constructed = + value.kind() == MmsDataKind::array || value.kind() == MmsDataKind::structure; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, + constructed, + *tag, + *content)) { + return false; + } + + switch (value.kind()) { + case MmsDataKind::array: + case MmsDataKind::structure: + for (const auto& child : value.children()) { + if (!write_value(writer, child, depth + 1U)) { + return false; + } + } + return true; + case MmsDataKind::boolean: { + const auto* scalar = std::get_if(&value.value()); + return scalar != nullptr && writer.write_byte(*scalar ? 0x01U : 0x00U); + } + case MmsDataKind::bit_string: + case MmsDataKind::octet_string: + case MmsDataKind::binary_time: + case MmsDataKind::unknown: + return writer.write_bytes(value.raw_value()); + case MmsDataKind::integer: { + const auto* scalar = std::get_if(&value.value()); + return scalar != nullptr && write_signed(writer, *scalar); + } + case MmsDataKind::unsigned_integer: { + const auto* scalar = std::get_if(&value.value()); + return scalar != nullptr && write_unsigned(writer, *scalar); + } + case MmsDataKind::floating_point: { + float scalar = 0.0F; + if (const auto* single = std::get_if(&value.value())) { + scalar = *single; + } else if (const auto* double_value = std::get_if(&value.value())) { + scalar = static_cast(*double_value); + } else { + return false; + } + const auto raw = std::bit_cast(scalar); + return writer.write_byte(0x08U) && + writer.write_byte(static_cast((raw >> 24U) & 0xFFU)) && + writer.write_byte(static_cast((raw >> 16U) & 0xFFU)) && + writer.write_byte(static_cast((raw >> 8U) & 0xFFU)) && + writer.write_byte(static_cast(raw & 0xFFU)); + } + case MmsDataKind::visible_string: + case MmsDataKind::mms_string: { + const auto* text = std::get_if(&value.value()); + if (text == nullptr) { + return false; + } + for (const auto character : *text) { + if (!writer.write_byte(static_cast(character))) { + return false; + } + } + return true; + } + case MmsDataKind::utc_time: { + const auto* utc = std::get_if(&value.value()); + if (utc == nullptr) { + return false; + } + std::array bytes{}; + return utc->try_write_bytes(bytes) && writer.write_bytes(bytes); + } + default: + return false; + } + } +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/information_report_span.hpp b/include/ariec61850/mms/information_report_span.hpp new file mode 100644 index 000000000..c1d1ee1f6 --- /dev/null +++ b/include/ariec61850/mms/information_report_span.hpp @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/services_span.hpp" +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +struct MmsInformationReportReferenceInput final { + std::string_view domain; + std::string_view item; +}; + +struct MmsInformationReportSnapshotInput final { + std::string_view report_id; + std::span optional_fields; + std::uint32_t sequence_number{}; + std::span report_time; + MmsInformationReportReferenceInput data_set_reference{}; + bool buffered{}; + bool buffer_overflow{}; + std::span entry_id; + std::uint32_t conf_revision{}; + std::span member_references; + std::span member_results; + std::uint8_t reason_for_inclusion{}; +}; + +struct MmsInformationReportView final { + MmsObjectNameView variable_list_name{}; + std::span access_result_list{}; + std::size_t item_count{}; + + [[nodiscard]] bool try_item( + std::size_t index, + MmsReadAccessResultView& result) const noexcept; +}; + +class MmsInformationReportSpanCodec final { +public: + static constexpr std::size_t maximum_members = MmsServiceSpanCodec::maximum_variables; + static constexpr std::size_t maximum_reference_bytes = 129U; + static constexpr std::size_t maximum_report_id_bytes = 129U; + static constexpr std::size_t optional_field_bytes = 2U; + static constexpr std::size_t binary_time_bytes = 6U; + static constexpr std::size_t entry_id_bytes = 8U; + static constexpr std::size_t maximum_access_results = + (3U * maximum_members) + 16U; + + [[nodiscard]] static bool try_decode_information_report( + std::span mms_pdu, + MmsInformationReportView& report) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_snapshot_into( + const MmsInformationReportSnapshotInput& report, + std::span destination) noexcept; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/pdu_span.hpp b/include/ariec61850/mms/pdu_span.hpp new file mode 100644 index 000000000..5617c756a --- /dev/null +++ b/include/ariec61850/mms/pdu_span.hpp @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsWirePduKind : std::uint8_t { + unknown, + confirmed_request, + confirmed_response, + initiate_request, + initiate_response, +}; + +enum class MmsWireConfirmedService : std::int32_t { + unknown = -1, + get_name_list = 1, + identify = 2, + read = 4, + write = 5, + get_variable_access_attributes = 6, + get_named_variable_list_attributes = 12, +}; + +struct MmsInitiateDetailView final { + std::uint32_t version_number{}; + std::span parameter_support_options{}; + std::span services_supported_calling{}; +}; + +struct MmsInitiateView final { + MmsWirePduKind kind{MmsWirePduKind::unknown}; + std::uint32_t maximum_mms_pdu_size{}; + std::uint32_t maximum_outstanding_calling{}; + std::uint32_t maximum_outstanding_called{}; + std::uint32_t data_structure_nesting_level{}; + MmsInitiateDetailView detail{}; +}; + +struct MmsConfirmedPduView final { + MmsWirePduKind kind{MmsWirePduKind::unknown}; + std::uint32_t invoke_id{}; + std::int32_t service_tag{-1}; + bool service_constructed{}; + std::span service_value{}; + + [[nodiscard]] MmsWireConfirmedService service() const noexcept; +}; + +class MmsPduSpanCodec final { +public: + static constexpr std::size_t maximum_pdu_bytes = 1U * 1024U * 1024U; + static constexpr std::size_t maximum_bit_string_bytes = 256U; + static constexpr std::uint32_t maximum_invoke_id = 0x7FFF'FFFFU; + + [[nodiscard]] static bool try_decode_initiate_request_view( + std::span bytes, + MmsInitiateView& request) noexcept; + + [[nodiscard]] static bool try_decode_initiate_response_view( + std::span bytes, + MmsInitiateView& response) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_default_initiate_response_into( + std::span destination) noexcept; + + [[nodiscard]] static bool try_decode_confirmed_request_view( + std::span bytes, + MmsConfirmedPduView& request) noexcept; + + [[nodiscard]] static bool try_decode_confirmed_response_view( + std::span bytes, + MmsConfirmedPduView& response) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_confirmed_request_into( + std::uint32_t invoke_id, + std::int32_t service_tag, + bool service_constructed, + std::span service_value, + std::span destination) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_confirmed_response_into( + std::uint32_t invoke_id, + std::int32_t service_tag, + bool service_constructed, + std::span service_value, + std::span destination) noexcept; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/selective_information_report_span.hpp b/include/ariec61850/mms/selective_information_report_span.hpp new file mode 100644 index 000000000..f6f516b4d --- /dev/null +++ b/include/ariec61850/mms/selective_information_report_span.hpp @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/information_report_span.hpp" +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +// Event-report form of InformationReport. The inclusion bit-string keeps the +// original DataSet cardinality while only the selected member values are +// encoded. This is the shape required by change-driven IEC 61850 reports. +struct MmsSelectiveInformationReportSnapshotInput final { + std::string_view report_id; + std::span optional_fields; + std::uint32_t sequence_number{}; + std::span report_time; + MmsInformationReportReferenceInput data_set_reference{}; + std::uint32_t conf_revision{}; + + std::size_t data_set_member_count{}; + std::span included_member_indices; + std::span included_member_references; + std::span included_member_results; + std::span included_reason_for_inclusion; +}; + +class MmsSelectiveInformationReportSpanCodec final { +public: + [[nodiscard]] static wire::EncodeResult encode_snapshot_into( + const MmsSelectiveInformationReportSnapshotInput& report, + std::span destination) noexcept; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/services_span.hpp b/include/ariec61850/mms/services_span.hpp new file mode 100644 index 000000000..2f6d172da --- /dev/null +++ b/include/ariec61850/mms/services_span.hpp @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/pdu_span.hpp" +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsNameListObjectClass : std::uint32_t { + named_variable = 0U, + named_variable_list = 2U, + domain = 9U, +}; + +enum class MmsNameScopeKind : std::uint8_t { + vmd_specific, + domain_specific, + aa_specific, +}; + +enum class MmsObjectNameViewKind : std::uint8_t { + vmd_specific, + domain_specific, + aa_specific, +}; + +struct MmsObjectNameView final { + MmsObjectNameViewKind kind{MmsObjectNameViewKind::domain_specific}; + std::span domain{}; + std::span item{}; +}; + +struct MmsGetNameListRequestView final { + std::uint32_t invoke_id{}; + MmsNameListObjectClass object_class{MmsNameListObjectClass::domain}; + MmsNameScopeKind scope{MmsNameScopeKind::vmd_specific}; + std::span domain_id{}; + std::span continue_after{}; +}; + +struct MmsGetNameListResponseView final { + std::uint32_t invoke_id{}; + std::span identifier_list{}; + std::size_t identifier_count{}; + bool more_follows{}; + + [[nodiscard]] bool try_identifier( + std::size_t index, + std::span& identifier) const noexcept; +}; + +struct MmsVariableAccessAttributesRequestView final { + std::uint32_t invoke_id{}; + MmsObjectNameView name{}; +}; + +struct MmsVariableAccessAttributesResponseView final { + std::uint32_t invoke_id{}; + bool mms_deletable{}; + std::span type_specification{}; +}; + +struct MmsReadRequestView final { + std::uint32_t invoke_id{}; + bool specification_with_result{}; + std::span variable_list{}; + std::size_t variable_count{}; + + [[nodiscard]] bool try_variable( + std::size_t index, + MmsObjectNameView& name) const noexcept; +}; + +struct MmsReadAccessResultView final { + bool success{}; + std::span encoded_data{}; + std::uint32_t failure_code{}; +}; + +struct MmsReadResponseView final { + std::uint32_t invoke_id{}; + std::span access_result_list{}; + std::size_t result_count{}; + + [[nodiscard]] bool try_result( + std::size_t index, + MmsReadAccessResultView& result) const noexcept; +}; + +struct MmsReadAccessResultInput final { + bool success{}; + std::span encoded_data{}; + std::uint32_t failure_code{}; +}; + +struct MmsWriteRequestView final { + std::uint32_t invoke_id{}; + std::span variable_list{}; + std::size_t variable_count{}; + std::span data_list{}; + std::size_t data_count{}; + + [[nodiscard]] bool try_variable( + std::size_t index, + MmsObjectNameView& name) const noexcept; + + [[nodiscard]] bool try_value( + std::size_t index, + std::span& encoded_data) const noexcept; +}; + +struct MmsWriteAccessResultView final { + bool success{}; + std::uint32_t failure_code{}; +}; + +struct MmsWriteResponseView final { + std::uint32_t invoke_id{}; + std::span result_list{}; + std::size_t result_count{}; + bool implicit_single_success{}; + + [[nodiscard]] bool try_result( + std::size_t index, + MmsWriteAccessResultView& result) const noexcept; +}; + +struct MmsWriteAccessResultInput final { + bool success{}; + std::uint32_t failure_code{}; +}; + +class MmsServiceSpanCodec final { +public: + static constexpr std::size_t maximum_identifier_bytes = 1'024U; + static constexpr std::size_t maximum_identifiers = 128U; + static constexpr std::size_t maximum_variables = 64U; + + [[nodiscard]] static bool try_decode_object_name_view( + std::span encoded_object_name, + MmsObjectNameView& name) noexcept; + + [[nodiscard]] static bool try_decode_get_name_list_request( + const MmsConfirmedPduView& confirmed, + MmsGetNameListRequestView& request) noexcept; + [[nodiscard]] static bool try_decode_get_name_list_request( + std::span mms_pdu, + MmsGetNameListRequestView& request) noexcept; + [[nodiscard]] static bool try_decode_get_name_list_response( + const MmsConfirmedPduView& confirmed, + MmsGetNameListResponseView& response) noexcept; + [[nodiscard]] static bool try_decode_get_name_list_response( + std::span mms_pdu, + MmsGetNameListResponseView& response) noexcept; + [[nodiscard]] static wire::EncodeResult encode_get_name_list_response_into( + std::uint32_t invoke_id, + std::span names, + bool more_follows, + std::span destination) noexcept; + + [[nodiscard]] static bool try_decode_variable_access_attributes_request( + const MmsConfirmedPduView& confirmed, + MmsVariableAccessAttributesRequestView& request) noexcept; + [[nodiscard]] static bool try_decode_variable_access_attributes_request( + std::span mms_pdu, + MmsVariableAccessAttributesRequestView& request) noexcept; + [[nodiscard]] static bool try_decode_variable_access_attributes_response( + const MmsConfirmedPduView& confirmed, + MmsVariableAccessAttributesResponseView& response) noexcept; + [[nodiscard]] static bool try_decode_variable_access_attributes_response( + std::span mms_pdu, + MmsVariableAccessAttributesResponseView& response) noexcept; + [[nodiscard]] static wire::EncodeResult encode_variable_access_attributes_response_into( + std::uint32_t invoke_id, + bool mms_deletable, + std::span encoded_type_specification, + std::span destination) noexcept; + + [[nodiscard]] static bool try_decode_read_request( + const MmsConfirmedPduView& confirmed, + MmsReadRequestView& request) noexcept; + [[nodiscard]] static bool try_decode_read_request( + std::span mms_pdu, + MmsReadRequestView& request) noexcept; + [[nodiscard]] static bool try_decode_read_response( + const MmsConfirmedPduView& confirmed, + MmsReadResponseView& response) noexcept; + [[nodiscard]] static bool try_decode_read_response( + std::span mms_pdu, + MmsReadResponseView& response) noexcept; + [[nodiscard]] static wire::EncodeResult encode_read_response_into( + std::uint32_t invoke_id, + std::span results, + std::span destination) noexcept; + + [[nodiscard]] static bool try_decode_write_request( + const MmsConfirmedPduView& confirmed, + MmsWriteRequestView& request) noexcept; + [[nodiscard]] static bool try_decode_write_request( + std::span mms_pdu, + MmsWriteRequestView& request) noexcept; + [[nodiscard]] static bool try_decode_write_response( + const MmsConfirmedPduView& confirmed, + MmsWriteResponseView& response) noexcept; + [[nodiscard]] static bool try_decode_write_response( + std::span mms_pdu, + MmsWriteResponseView& response) noexcept; + [[nodiscard]] static wire::EncodeResult encode_write_response_into( + std::uint32_t invoke_id, + std::span results, + std::span destination) noexcept; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_brcb_checkpoint_store.hpp b/include/ariec61850/mms/static_brcb_checkpoint_store.hpp new file mode 100644 index 000000000..3c8cd80ac --- /dev/null +++ b/include/ariec61850/mms/static_brcb_checkpoint_store.hpp @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/static_brcb_state_codec.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +class MmsStaticBrcbRuntime; + +struct MmsStaticBrcbStorageBackend final { + using ReadFn = bool (*)( + void* context, + std::size_t offset, + std::span destination) noexcept; + using WriteFn = bool (*)( + void* context, + std::size_t offset, + std::span source) noexcept; + using EraseFn = bool (*)( + void* context, + std::size_t offset, + std::size_t bytes) noexcept; + using SyncFn = bool (*)(void* context) noexcept; + + void* context{}; + std::size_t capacity_bytes{}; + ReadFn read{}; + WriteFn write{}; + EraseFn erase{}; + SyncFn sync{}; + + [[nodiscard]] constexpr bool valid() const noexcept { + return capacity_bytes != 0U && read != nullptr && write != nullptr && + erase != nullptr && sync != nullptr; + } +}; + +enum class MmsStaticBrcbCheckpointStatus : std::uint8_t { + ok, + no_checkpoint, + invalid_backend, + invalid_layout, + state_buffer_too_small, + storage_failure, + state_encode_failed, + state_restore_failed, +}; + +struct MmsStaticBrcbCheckpointResult final { + MmsStaticBrcbCheckpointStatus status{ + MmsStaticBrcbCheckpointStatus::invalid_backend}; + MmsStaticBrcbStateStatus state_status{MmsStaticBrcbStateStatus::ok}; + std::uint64_t generation{}; + std::size_t bank_index{}; + std::size_t state_bytes{}; + std::size_t required_state_bytes{}; + + [[nodiscard]] constexpr bool success() const noexcept { + return status == MmsStaticBrcbCheckpointStatus::ok; + } +}; + +// Two fixed banks are used as an A/B journal. The inactive bank is erased and +// rewritten first; its commit footer is written only after the complete state +// image has been written and synchronized. The previous committed bank is kept +// intact as the recovery fallback. +class MmsStaticBrcbCheckpointStore final { +public: + static constexpr std::uint16_t format_version = 1U; + static constexpr std::size_t bank_count = 2U; + static constexpr std::size_t record_header_bytes = 40U; + static constexpr std::size_t record_footer_bytes = 32U; + + MmsStaticBrcbCheckpointStore( + MmsStaticBrcbStorageBackend backend, + std::size_t bank_bytes) noexcept + : backend_{backend}, bank_bytes_{bank_bytes} {} + + [[nodiscard]] bool valid() const noexcept; + [[nodiscard]] constexpr std::size_t bank_bytes() const noexcept { + return bank_bytes_; + } + [[nodiscard]] constexpr std::size_t maximum_state_bytes() const noexcept { + return bank_bytes_ >= record_header_bytes + record_footer_bytes + ? bank_bytes_ - record_header_bytes - record_footer_bytes + : 0U; + } + + [[nodiscard]] MmsStaticBrcbCheckpointResult checkpoint( + const MmsStaticBrcbRuntime& runtime, + std::span state_buffer) const noexcept; + + [[nodiscard]] MmsStaticBrcbCheckpointResult restore( + MmsStaticBrcbRuntime& runtime, + std::span state_buffer) const noexcept; + +private: + MmsStaticBrcbStorageBackend backend_{}; + std::size_t bank_bytes_{}; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_brcb_connection.hpp b/include/ariec61850/mms/static_brcb_connection.hpp new file mode 100644 index 000000000..2bf50ee43 --- /dev/null +++ b/include/ariec61850/mms/static_brcb_connection.hpp @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/connection_runtime.hpp" +#include "ariec61850/mms/static_brcb_runtime.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsStaticBrcbConnectionStatus : std::uint8_t { + no_report_available, + reporting_disabled, + response_ready, + not_established, + response_buffer_too_small, + workspace_too_small, + frame_encode_failed, + stale_entry, +}; + +struct MmsStaticBrcbConnectionResult final { + MmsStaticBrcbConnectionStatus status{ + MmsStaticBrcbConnectionStatus::no_report_available}; + std::size_t bytes_written{}; + std::size_t required_response_bytes{}; + std::size_t required_workspace_bytes{}; + std::array entry_id{}; + std::uint8_t sequence_number{}; + bool buffer_overflow{}; + + [[nodiscard]] constexpr bool response_ready() const noexcept { + return status == MmsStaticBrcbConnectionStatus::response_ready; + } +}; + +class MmsStaticBrcbConnection final { +public: + // Deliver at most one already-buffered report while reporting is enabled. + // The queue front is removed only after a complete TPKT image is staged + // successfully. Capacity errors, disabled reporting and disconnected + // sessions preserve the exact same EntryID for retry. + [[nodiscard]] static MmsStaticBrcbConnectionResult poll( + const MmsStaticConnectionRuntime& connection, + MmsStaticBrcbRuntime& reports, + std::span response, + std::span workspace) noexcept; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_brcb_runtime.hpp b/include/ariec61850/mms/static_brcb_runtime.hpp new file mode 100644 index 000000000..72e605d4b --- /dev/null +++ b/include/ariec61850/mms/static_brcb_runtime.hpp @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/buffered_selective_information_report_span.hpp" +#include "ariec61850/mms/static_data_set_table.hpp" +#include "ariec61850/mms/static_object_table.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +class MmsStaticBrcbStateCodec; + +enum class MmsStaticBrcbStatus : std::uint8_t { + ok, + invalid_runtime, + invalid_definition, + member_out_of_range, + trigger_not_selected, + temporarily_unavailable, + no_report_due, + stale_plan, + data_set_not_found, + object_not_found, + workspace_too_small, + response_buffer_too_small, + slot_too_small, + backend_failure, + report_encode_failed, + entry_not_found, +}; + +enum class MmsStaticBrcbEventReason : std::uint8_t { + data_change, + quality_change, + data_update, +}; + +struct MmsStaticBrcbDefinition final { + std::string_view domain; + std::string_view item; + std::string_view report_id; + std::string_view data_set_domain; + std::string_view data_set_item; + std::uint32_t conf_revision{1U}; + std::array + optional_fields{}; + std::uint32_t buffer_time_ms{}; + std::uint8_t trigger_options{}; +}; + +// Payload storage is caller-owned so applications choose the queue RAM budget. +// `storage` must remain valid for the lifetime of the runtime. +struct MmsStaticBrcbSlot final { + std::span storage{}; + std::array entry_id{}; + std::size_t bytes{}; + std::uint8_t sequence_number{}; + bool buffer_overflow{}; + bool occupied{}; +}; + +struct MmsStaticBrcbPendingState final { + std::array + member_reason_masks{}; + std::uint64_t due_ms{}; + std::uint32_t revision{}; + std::size_t pending_member_count{}; + bool pending{}; +}; + +struct MmsStaticBrcbCapturePlan final { + std::uint32_t pending_revision{}; + std::uint32_t queue_revision{}; + std::uint64_t entry_number{}; + std::uint8_t sequence_number{}; + bool buffer_overflow{}; +}; + +struct MmsStaticBrcbCaptureResult final { + MmsStaticBrcbStatus status{MmsStaticBrcbStatus::invalid_runtime}; + std::size_t bytes_written{}; + std::size_t required_bytes{}; + std::size_t included_member_count{}; + std::array entry_id{}; + + [[nodiscard]] constexpr bool success() const noexcept { + return status == MmsStaticBrcbStatus::ok; + } +}; + +struct MmsStaticBrcbEntryView final { + std::span mms_pdu{}; + std::span entry_id{}; + std::uint8_t sequence_number{}; + bool buffer_overflow{}; +}; + +// First bounded BRCB profile: one runtime instance represents one BRCB. It +// buffers event snapshots in caller-owned RAM across connection resets. Queue +// persistence is storage-agnostic and mediated by MmsStaticBrcbStateCodec. +class MmsStaticBrcbRuntime final { +public: + MmsStaticBrcbRuntime( + const MmsStaticBrcbDefinition& definition, + MmsStaticBrcbPendingState& pending, + std::span slots, + const MmsStaticObjectTable& objects, + const MmsStaticDataSetTable& data_sets) noexcept + : definition_{&definition}, pending_{&pending}, slots_{slots}, + objects_{&objects}, data_sets_{&data_sets} {} + + [[nodiscard]] bool initialize() noexcept; + [[nodiscard]] constexpr bool valid() const noexcept { return initialized_; } + + [[nodiscard]] MmsStaticBrcbStatus set_enabled(bool enabled) noexcept; + [[nodiscard]] constexpr bool enabled() const noexcept { return enabled_; } + + [[nodiscard]] MmsStaticBrcbStatus notify( + std::size_t data_set_member_index, + MmsStaticBrcbEventReason reason, + std::uint64_t now_ms) noexcept; + + [[nodiscard]] bool next_due( + std::uint64_t now_ms, + MmsStaticBrcbCapturePlan& plan) const noexcept; + + [[nodiscard]] MmsStaticBrcbCaptureResult capture( + const MmsStaticBrcbCapturePlan& plan, + std::span report_time, + std::span encode_buffer, + std::span workspace) noexcept; + + [[nodiscard]] bool front(MmsStaticBrcbEntryView& entry) const noexcept; + [[nodiscard]] MmsStaticBrcbStatus commit_delivery( + std::span expected_entry_id) noexcept; + + [[nodiscard]] constexpr std::size_t queue_size() const noexcept { return count_; } + [[nodiscard]] constexpr std::size_t queue_capacity() const noexcept { + return slots_.size(); + } + [[nodiscard]] constexpr std::uint64_t dropped_reports() const noexcept { + return dropped_reports_; + } + +private: + friend class MmsStaticBrcbStateCodec; + + const MmsStaticBrcbDefinition* definition_{}; + MmsStaticBrcbPendingState* pending_{}; + std::span slots_{}; + const MmsStaticObjectTable* objects_{}; + const MmsStaticDataSetTable* data_sets_{}; + std::size_t head_{}; + std::size_t count_{}; + std::uint64_t next_entry_number_{1U}; + std::uint64_t dropped_reports_{}; + std::uint32_t queue_revision_{1U}; + std::uint8_t sequence_number_{}; + bool enabled_{}; + bool initialized_{}; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_brcb_state_codec.hpp b/include/ariec61850/mms/static_brcb_state_codec.hpp new file mode 100644 index 000000000..18f58da74 --- /dev/null +++ b/include/ariec61850/mms/static_brcb_state_codec.hpp @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include + +namespace ar::iec61850::mms { + +class MmsStaticBrcbRuntime; + +enum class MmsStaticBrcbStateStatus : std::uint8_t { + ok, + invalid_runtime, + buffer_too_small, + invalid_state, + definition_mismatch, + capacity_mismatch, +}; + +struct MmsStaticBrcbStateResult final { + MmsStaticBrcbStateStatus status{MmsStaticBrcbStateStatus::invalid_runtime}; + std::size_t bytes_written{}; + std::size_t required_bytes{}; + + [[nodiscard]] constexpr bool success() const noexcept { + return status == MmsStaticBrcbStateStatus::ok; + } +}; + +// Storage-agnostic bounded state image for the already-buffered BRCB queue. +// Pending BufTm coalescing is intentionally transient and is not restored. +class MmsStaticBrcbStateCodec final { +public: + static constexpr std::uint16_t format_version = 1U; + + [[nodiscard]] static MmsStaticBrcbStateResult encode( + const MmsStaticBrcbRuntime& runtime, + std::span destination) noexcept; + + [[nodiscard]] static MmsStaticBrcbStateResult restore( + MmsStaticBrcbRuntime& runtime, + std::span source) noexcept; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_data_set_table.hpp b/include/ariec61850/mms/static_data_set_table.hpp new file mode 100644 index 000000000..e7fc7a59d --- /dev/null +++ b/include/ariec61850/mms/static_data_set_table.hpp @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/data_set_span.hpp" +#include "ariec61850/mms/static_object_table.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +struct MmsStaticDataSetMember final { + std::string_view domain; + std::string_view item; +}; + +struct MmsStaticDataSetEntry final { + std::string_view domain; + std::string_view item; + std::span members; + bool mms_deletable{}; +}; + +class MmsStaticDataSetTable final { +public: + static constexpr std::size_t maximum_data_sets = 64U; + + constexpr MmsStaticDataSetTable() noexcept = default; + + explicit constexpr MmsStaticDataSetTable( + const std::span data_sets) noexcept + : data_sets_{data_sets} {} + + [[nodiscard]] bool valid() const noexcept; + + [[nodiscard]] bool valid_against( + const MmsStaticObjectTable& objects) const noexcept; + + [[nodiscard]] const MmsStaticDataSetEntry* find( + const MmsObjectNameView& name) const noexcept; + + [[nodiscard]] constexpr std::span data_sets() const noexcept { + return data_sets_; + } + +private: + std::span data_sets_{}; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_dispatcher.hpp b/include/ariec61850/mms/static_dispatcher.hpp new file mode 100644 index 000000000..87c65d580 --- /dev/null +++ b/include/ariec61850/mms/static_dispatcher.hpp @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/static_data_set_table.hpp" +#include "ariec61850/mms/static_object_table.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsStaticDispatchStatus : std::uint8_t { + response_ready, + malformed_request, + unsupported_service, + unsupported_request, + object_not_found, + invalid_object_table, + workspace_too_small, + response_buffer_too_small, + backend_failure, +}; + +struct MmsStaticDispatchPolicy final { + std::size_t maximum_names_per_response{32U}; + std::size_t maximum_write_variables{1U}; + std::uint32_t missing_object_failure_code{10U}; + std::uint32_t access_denied_failure_code{3U}; + std::uint32_t backend_failure_code{10U}; +}; + +struct MmsStaticDispatchResult final { + MmsStaticDispatchStatus status{MmsStaticDispatchStatus::malformed_request}; + MmsWireConfirmedService service{MmsWireConfirmedService::unknown}; + std::uint32_t invoke_id{}; + std::size_t bytes_written{}; + std::size_t required_bytes{}; + + [[nodiscard]] constexpr bool success() const noexcept { + return status == MmsStaticDispatchStatus::response_ready; + } +}; + +class MmsStaticApplicationDispatcher final { +public: + explicit constexpr MmsStaticApplicationDispatcher( + const MmsStaticObjectTable& objects, + const MmsStaticDispatchPolicy policy = {}) noexcept + : objects_{objects}, policy_{policy} {} + + constexpr MmsStaticApplicationDispatcher( + const MmsStaticObjectTable& objects, + const MmsStaticDataSetTable& data_sets, + const MmsStaticDispatchPolicy policy = {}) noexcept + : objects_{objects}, data_sets_{data_sets}, policy_{policy} {} + + [[nodiscard]] MmsStaticDispatchResult dispatch( + std::span mms_request, + std::span response, + std::span workspace) const noexcept; + + [[nodiscard]] MmsStaticDispatchResult dispatch( + const MmsConfirmedPduView& request, + std::span response, + std::span workspace) const noexcept; + + [[nodiscard]] constexpr const MmsStaticDispatchPolicy& policy() const noexcept { + return policy_; + } + + [[nodiscard]] constexpr const MmsStaticDataSetTable& data_sets() const noexcept { + return data_sets_; + } + +private: + const MmsStaticObjectTable& objects_; + MmsStaticDataSetTable data_sets_{}; + MmsStaticDispatchPolicy policy_{}; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_information_report.hpp b/include/ariec61850/mms/static_information_report.hpp new file mode 100644 index 000000000..8aa58978a --- /dev/null +++ b/include/ariec61850/mms/static_information_report.hpp @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/information_report_span.hpp" +#include "ariec61850/mms/static_data_set_table.hpp" +#include "ariec61850/mms/static_object_table.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsStaticInformationReportStatus : std::uint8_t { + response_ready, + invalid_object_table, + invalid_data_set_table, + data_set_not_found, + object_not_found, + workspace_too_small, + backend_failure, + invalid_report, + response_buffer_too_small, +}; + +struct MmsStaticInformationReportInput final { + std::string_view report_id; + std::span optional_fields; + std::uint32_t sequence_number{}; + std::span report_time; + bool buffered{}; + bool buffer_overflow{}; + std::span entry_id; + std::uint32_t conf_revision{}; + std::uint8_t reason_for_inclusion{}; +}; + +struct MmsStaticInformationReportEncodeResult final { + MmsStaticInformationReportStatus status{ + MmsStaticInformationReportStatus::invalid_report}; + std::size_t bytes_written{}; + std::size_t required_bytes{}; + std::size_t member_count{}; + + [[nodiscard]] constexpr bool success() const noexcept { + return status == MmsStaticInformationReportStatus::response_ready; + } +}; + +class MmsStaticInformationReportEncoder final { +public: + [[nodiscard]] static MmsStaticInformationReportEncodeResult + encode_data_set_snapshot_into( + const MmsStaticObjectTable& objects, + const MmsStaticDataSetTable& data_sets, + const MmsObjectNameView& data_set_name, + const MmsStaticInformationReportInput& report, + std::span destination, + std::span workspace) noexcept; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_object_table.hpp b/include/ariec61850/mms/static_object_table.hpp new file mode 100644 index 000000000..f533cdabe --- /dev/null +++ b/include/ariec61850/mms/static_object_table.hpp @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/services_span.hpp" +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +using MmsStaticReadCallback = wire::EncodeResult (*)( + const void* context, + std::span destination) noexcept; + +struct MmsStaticWriteResult final { + bool success{}; + std::uint32_t failure_code{}; +}; + +using MmsStaticWriteCallback = MmsStaticWriteResult (*)( + void* context, + std::span encoded_data) noexcept; + +struct MmsStaticObjectEntry final { + std::string_view domain; + std::string_view item; + std::span type_specification; + MmsStaticReadCallback read{}; + const void* context{}; + bool mms_deletable{}; + MmsStaticWriteCallback write{}; + void* write_context{}; + + [[nodiscard]] constexpr bool writable() const noexcept { + return write != nullptr; + } +}; + +class MmsStaticObjectTable final { +public: + static constexpr std::size_t maximum_objects = 128U; + + explicit constexpr MmsStaticObjectTable( + const std::span objects) noexcept + : objects_{objects} {} + + [[nodiscard]] bool valid() const noexcept; + + [[nodiscard]] const MmsStaticObjectEntry* find( + const MmsObjectNameView& name) const noexcept; + + [[nodiscard]] bool try_resolve_read_request( + const MmsReadRequestView& request, + std::span resolved, + std::size_t& resolved_count) const noexcept; + + [[nodiscard]] bool try_resolve_write_request( + const MmsWriteRequestView& request, + std::span resolved, + std::size_t& resolved_count) const noexcept; + + [[nodiscard]] constexpr std::span objects() const noexcept { + return objects_; + } + +private: + std::span objects_; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_report_connection.hpp b/include/ariec61850/mms/static_report_connection.hpp new file mode 100644 index 000000000..dd37f6cc2 --- /dev/null +++ b/include/ariec61850/mms/static_report_connection.hpp @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/connection_runtime.hpp" +#include "ariec61850/mms/static_urcb_runtime.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsStaticReportConnectionStatus : std::uint8_t { + no_report_due, + response_ready, + not_established, + response_buffer_too_small, + workspace_too_small, + report_encode_failed, + stale_plan, +}; + +struct MmsStaticReportConnectionResult final { + MmsStaticReportConnectionStatus status{MmsStaticReportConnectionStatus::no_report_due}; + MmsStaticUrcbStatus urcb_status{MmsStaticUrcbStatus::no_report_due}; + std::size_t bytes_written{}; + std::size_t required_response_bytes{}; + std::size_t required_workspace_bytes{}; + std::size_t control_block_index{MmsStaticUrcbEmissionPlan::invalid_index}; + std::uint8_t sequence_number{}; + MmsStaticUrcbReportReason reason{MmsStaticUrcbReportReason::none}; + + [[nodiscard]] constexpr bool response_ready() const noexcept { + return status == MmsStaticReportConnectionStatus::response_ready; + } +}; + +class MmsStaticReportConnection final { +public: + // Poll one due URCB report for an already-established MMS connection. + // The URCB plan is committed only after a complete TPKT frame is encoded, + // so capacity retries cannot consume GI/integrity state or skip SqNum. + [[nodiscard]] static MmsStaticReportConnectionResult poll( + const MmsStaticConnectionRuntime& connection, + MmsStaticUrcbRuntime& reports, + std::uint64_t now_ms, + std::span report_time, + std::span response, + std::span workspace) noexcept; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_urcb_event_connection.hpp b/include/ariec61850/mms/static_urcb_event_connection.hpp new file mode 100644 index 000000000..dfec1c6a8 --- /dev/null +++ b/include/ariec61850/mms/static_urcb_event_connection.hpp @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/connection_runtime.hpp" +#include "ariec61850/mms/static_urcb_event_runtime.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsStaticUrcbEventConnectionStatus : std::uint8_t { + no_report_due, + response_ready, + not_established, + response_buffer_too_small, + workspace_too_small, + report_encode_failed, + stale_plan, +}; + +struct MmsStaticUrcbEventConnectionResult final { + MmsStaticUrcbEventConnectionStatus status{ + MmsStaticUrcbEventConnectionStatus::no_report_due}; + MmsStaticUrcbEventStatus event_status{MmsStaticUrcbEventStatus::ok}; + std::size_t bytes_written{}; + std::size_t required_response_bytes{}; + std::size_t required_workspace_bytes{}; + std::size_t control_block_index{MmsStaticUrcbEventPlan::invalid_index}; + std::uint8_t sequence_number{}; + + [[nodiscard]] constexpr bool response_ready() const noexcept { + return status == MmsStaticUrcbEventConnectionStatus::response_ready; + } +}; + +class MmsStaticUrcbEventConnection final { +public: + // Poll exactly one due event-driven URCB report on an established MMS + // association. The event plan is committed only after the complete TPKT + // frame exists, preserving pending membership/reasons and SqNum across + // response/workspace capacity retries. + [[nodiscard]] static MmsStaticUrcbEventConnectionResult poll( + const MmsStaticConnectionRuntime& connection, + MmsStaticUrcbEventRuntime& events, + std::uint64_t now_ms, + std::span report_time, + std::span response, + std::span workspace) noexcept; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_urcb_event_runtime.hpp b/include/ariec61850/mms/static_urcb_event_runtime.hpp new file mode 100644 index 000000000..a81651975 --- /dev/null +++ b/include/ariec61850/mms/static_urcb_event_runtime.hpp @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/selective_information_report_span.hpp" +#include "ariec61850/mms/static_urcb_runtime.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsStaticUrcbEventStatus : std::uint8_t { + ok, + invalid_runtime, + index_out_of_range, + member_out_of_range, + temporarily_unavailable, + trigger_not_selected, + data_set_not_found, + stale_plan, + workspace_too_small, + object_not_found, + backend_failure, + response_buffer_too_small, + report_encode_failed, +}; + +enum class MmsStaticUrcbEventReason : std::uint8_t { + data_change, + quality_change, + data_update, +}; + +struct MmsStaticUrcbEventState final { + std::array + member_reason_masks{}; + std::uint64_t due_ms{}; + std::uint32_t source_revision{}; + std::uint32_t revision{}; + std::size_t pending_member_count{}; + bool pending{}; +}; + +struct MmsStaticUrcbEventPlan final { + static constexpr std::size_t invalid_index = + std::numeric_limits::max(); + + std::size_t index{invalid_index}; + std::uint32_t source_revision{}; + std::uint32_t event_revision{}; + std::uint8_t sequence_number{}; +}; + +struct MmsStaticUrcbEventEncodeResult final { + MmsStaticUrcbEventStatus status{MmsStaticUrcbEventStatus::invalid_runtime}; + std::size_t bytes_written{}; + std::size_t required_bytes{}; + std::size_t data_set_member_count{}; + std::size_t included_member_count{}; + + [[nodiscard]] constexpr bool success() const noexcept { + return status == MmsStaticUrcbEventStatus::ok; + } +}; + +// Change-trigger extension for the bounded URCB runtime. GI/integrity scheduling +// remains in MmsStaticUrcbRuntime; this class owns only dchg/qchg/dupd pending +// state and BufTm coalescing. +class MmsStaticUrcbEventRuntime final { +public: + MmsStaticUrcbEventRuntime( + MmsStaticUrcbRuntime& urcb, + std::span states, + const MmsStaticObjectTable& objects, + const MmsStaticDataSetTable& data_sets) noexcept + : urcb_{&urcb}, states_{states}, objects_{&objects}, data_sets_{&data_sets} {} + + [[nodiscard]] bool initialize() noexcept; + [[nodiscard]] constexpr bool valid() const noexcept { return initialized_; } + + [[nodiscard]] MmsStaticUrcbEventStatus notify( + std::size_t control_block_index, + std::size_t data_set_member_index, + MmsStaticUrcbEventReason reason, + std::uint64_t now_ms) noexcept; + + [[nodiscard]] bool next_due( + std::uint64_t now_ms, + MmsStaticUrcbEventPlan& plan) const noexcept; + + [[nodiscard]] MmsStaticUrcbEventEncodeResult encode( + const MmsStaticUrcbEventPlan& plan, + std::span report_time, + std::span destination, + std::span workspace) const noexcept; + + [[nodiscard]] MmsStaticUrcbEventStatus commit( + const MmsStaticUrcbEventPlan& plan) noexcept; + + [[nodiscard]] const MmsStaticUrcbEventState* state( + std::size_t index) const noexcept; + +private: + MmsStaticUrcbRuntime* urcb_{}; + std::span states_{}; + const MmsStaticObjectTable* objects_{}; + const MmsStaticDataSetTable* data_sets_{}; + bool initialized_{}; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_urcb_objects.hpp b/include/ariec61850/mms/static_urcb_objects.hpp new file mode 100644 index 000000000..ec3f1917c --- /dev/null +++ b/include/ariec61850/mms/static_urcb_objects.hpp @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/static_object_table.hpp" +#include "ariec61850/mms/static_urcb_runtime.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +using MmsStaticNowMsCallback = std::uint64_t (*)(const void* context) noexcept; + +enum class MmsStaticUrcbAttribute : std::uint8_t { + report_id, + report_enabled, + reserved, + data_set, + conf_revision, + optional_fields, + buffer_time, + trigger_options, + integrity_period, + general_interrogation, + sequence_number, +}; + +struct MmsStaticUrcbObjectContext final { + MmsStaticUrcbRuntime* runtime{}; + std::size_t index{}; + MmsStaticUrcbAttribute attribute{MmsStaticUrcbAttribute::report_id}; + MmsStaticNowMsCallback now_ms{}; + const void* now_context{}; +}; + +class MmsStaticUrcbObjectBank final { +public: + static constexpr std::size_t attributes_per_control_block = 11U; + + MmsStaticUrcbObjectBank( + MmsStaticUrcbRuntime& runtime, + std::span base_objects, + std::span object_storage, + std::span context_storage, + std::span name_storage, + MmsStaticNowMsCallback now_ms = nullptr, + const void* now_context = nullptr) noexcept + : runtime_{&runtime}, + base_objects_{base_objects}, + object_storage_{object_storage}, + context_storage_{context_storage}, + name_storage_{name_storage}, + now_ms_{now_ms}, + now_context_{now_context} {} + + [[nodiscard]] bool initialize() noexcept; + + [[nodiscard]] constexpr bool valid() const noexcept { return initialized_; } + [[nodiscard]] constexpr std::size_t object_count() const noexcept { + return initialized_ ? object_count_ : 0U; + } + [[nodiscard]] constexpr std::size_t name_bytes_used() const noexcept { + return initialized_ ? name_bytes_used_ : 0U; + } + + [[nodiscard]] std::size_t required_object_capacity() const noexcept; + [[nodiscard]] std::size_t required_context_capacity() const noexcept; + [[nodiscard]] std::size_t required_name_bytes() const noexcept; + + [[nodiscard]] constexpr const MmsStaticObjectTable& table() const noexcept { + return table_; + } + +private: + MmsStaticUrcbRuntime* runtime_{}; + std::span base_objects_{}; + std::span object_storage_{}; + std::span context_storage_{}; + std::span name_storage_{}; + MmsStaticNowMsCallback now_ms_{}; + const void* now_context_{}; + MmsStaticObjectTable table_{std::span{}}; + std::size_t object_count_{}; + std::size_t name_bytes_used_{}; + bool initialized_{}; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_urcb_runtime.hpp b/include/ariec61850/mms/static_urcb_runtime.hpp new file mode 100644 index 000000000..10a6a9b88 --- /dev/null +++ b/include/ariec61850/mms/static_urcb_runtime.hpp @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/static_information_report.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsStaticUrcbStatus : std::uint8_t { + ok, + invalid_runtime, + index_out_of_range, + invalid_value, + object_access_denied, + temporarily_unavailable, + data_set_not_found, + no_report_due, + stale_plan, + workspace_too_small, + backend_failure, + response_buffer_too_small, + report_encode_failed, +}; + +enum class MmsStaticUrcbReportReason : std::uint8_t { + none = 0x00U, + general_interrogation = 0x08U, + integrity = 0x10U, +}; + +struct MmsStaticUrcbDefinition final { + std::string_view domain; + std::string_view item; + std::string_view report_id; + std::string_view data_set_domain; + std::string_view data_set_item; + std::uint32_t conf_revision{1U}; + std::array + optional_fields{}; + std::uint32_t buffer_time_ms{}; + std::uint8_t trigger_options{}; + std::uint32_t integrity_period_ms{}; +}; + +struct MmsStaticUrcbState final { + static constexpr std::size_t maximum_report_id_bytes = + MmsInformationReportSpanCodec::maximum_report_id_bytes; + static constexpr std::size_t maximum_reference_component_bytes = + MmsInformationReportSpanCodec::maximum_reference_bytes; + + std::array report_id_storage{}; + std::size_t report_id_size{}; + std::array data_set_domain_storage{}; + std::size_t data_set_domain_size{}; + std::array data_set_item_storage{}; + std::size_t data_set_item_size{}; + std::array + optional_fields{}; + std::uint32_t conf_revision{1U}; + std::uint32_t buffer_time_ms{}; + std::uint32_t integrity_period_ms{}; + std::uint64_t next_integrity_due_ms{}; + std::uint32_t revision{}; + std::uint8_t trigger_options{}; + std::uint8_t sequence_number{}; + bool enabled{}; + bool reserved{}; + bool general_interrogation_pending{}; + bool integrity_armed{}; + + [[nodiscard]] std::string_view report_id() const noexcept { + return {report_id_storage.data(), report_id_size}; + } + + [[nodiscard]] std::string_view data_set_domain() const noexcept { + return {data_set_domain_storage.data(), data_set_domain_size}; + } + + [[nodiscard]] std::string_view data_set_item() const noexcept { + return {data_set_item_storage.data(), data_set_item_size}; + } +}; + +struct MmsStaticUrcbEmissionPlan final { + static constexpr std::size_t invalid_index = + std::numeric_limits::max(); + + std::size_t index{invalid_index}; + std::uint32_t revision{}; + std::uint8_t sequence_number{}; + MmsStaticUrcbReportReason reason{MmsStaticUrcbReportReason::none}; +}; + +struct MmsStaticUrcbEncodeResult final { + MmsStaticUrcbStatus status{MmsStaticUrcbStatus::invalid_runtime}; + std::size_t bytes_written{}; + std::size_t required_bytes{}; + std::size_t member_count{}; + + [[nodiscard]] constexpr bool success() const noexcept { + return status == MmsStaticUrcbStatus::ok; + } +}; + +class MmsStaticUrcbRuntime final { +public: + static constexpr std::size_t maximum_control_blocks = 16U; + static constexpr std::uint32_t minimum_integrity_period_ms = 100U; + + MmsStaticUrcbRuntime( + std::span definitions, + std::span states, + const MmsStaticObjectTable& objects, + const MmsStaticDataSetTable& data_sets) noexcept + : definitions_{definitions}, + states_{states}, + objects_{&objects}, + data_sets_{&data_sets} {} + + [[nodiscard]] bool initialize() noexcept; + [[nodiscard]] constexpr bool valid() const noexcept { return initialized_; } + [[nodiscard]] constexpr std::size_t size() const noexcept { + return initialized_ ? definitions_.size() : 0U; + } + + [[nodiscard]] const MmsStaticUrcbDefinition* definition( + std::size_t index) const noexcept; + [[nodiscard]] const MmsStaticUrcbState* state(std::size_t index) const noexcept; + [[nodiscard]] MmsStaticUrcbState* state(std::size_t index) noexcept; + + [[nodiscard]] bool find_index( + const MmsObjectNameView& name, + std::size_t& index) const noexcept; + + [[nodiscard]] MmsStaticUrcbStatus set_enabled( + std::size_t index, + bool enabled, + std::uint64_t now_ms) noexcept; + [[nodiscard]] MmsStaticUrcbStatus set_reserved( + std::size_t index, + bool reserved) noexcept; + [[nodiscard]] MmsStaticUrcbStatus set_report_id( + std::size_t index, + std::string_view report_id) noexcept; + [[nodiscard]] MmsStaticUrcbStatus set_data_set( + std::size_t index, + std::string_view domain, + std::string_view item) noexcept; + [[nodiscard]] MmsStaticUrcbStatus set_optional_fields( + std::size_t index, + std::span optional_fields) noexcept; + [[nodiscard]] MmsStaticUrcbStatus set_buffer_time_ms( + std::size_t index, + std::uint32_t buffer_time_ms) noexcept; + [[nodiscard]] MmsStaticUrcbStatus set_trigger_options( + std::size_t index, + std::uint8_t trigger_options) noexcept; + [[nodiscard]] MmsStaticUrcbStatus set_integrity_period_ms( + std::size_t index, + std::uint32_t integrity_period_ms) noexcept; + [[nodiscard]] MmsStaticUrcbStatus request_general_interrogation( + std::size_t index) noexcept; + + [[nodiscard]] bool next_due( + std::uint64_t now_ms, + MmsStaticUrcbEmissionPlan& plan) const noexcept; + + [[nodiscard]] MmsStaticUrcbEncodeResult encode( + const MmsStaticUrcbEmissionPlan& plan, + std::span report_time, + std::span destination, + std::span workspace) const noexcept; + + [[nodiscard]] MmsStaticUrcbStatus commit( + const MmsStaticUrcbEmissionPlan& plan, + std::uint64_t now_ms) noexcept; + +private: + std::span definitions_{}; + std::span states_{}; + const MmsStaticObjectTable* objects_{}; + const MmsStaticDataSetTable* data_sets_{}; + bool initialized_{}; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/utc_time.hpp b/include/ariec61850/mms/utc_time.hpp index 6b62af416..78d00ca1e 100644 --- a/include/ariec61850/mms/utc_time.hpp +++ b/include/ariec61850/mms/utc_time.hpp @@ -13,14 +13,24 @@ struct Iec61850UtcTime final { std::chrono::system_clock::time_point value{}; std::uint8_t quality{}; + // No-throw decode primitive for constrained/embedded profiles. + [[nodiscard]] static bool try_from_bytes( + std::span bytes, + Iec61850UtcTime& result) noexcept; + +#if !defined(ARIEC61850_NO_EXCEPTIONS) + // Host convenience wrapper retained when C++ exceptions are available. [[nodiscard]] static Iec61850UtcTime from_bytes(std::span bytes); +#endif // Allocation-free wire path for embedded publishers. Returns false when // the timestamp cannot be represented by IEC 61850's 32-bit seconds field. [[nodiscard]] bool try_write_bytes(std::span destination) const noexcept; +#if !defined(ARIEC61850_NO_EXCEPTIONS) // Host convenience wrapper retained for source compatibility. [[nodiscard]] std::vector to_bytes() const; +#endif friend bool operator==(const Iec61850UtcTime&, const Iec61850UtcTime&) = default; }; diff --git a/include/ariec61850/osi/cotp_span.hpp b/include/ariec61850/osi/cotp_span.hpp new file mode 100644 index 000000000..99a068250 --- /dev/null +++ b/include/ariec61850/osi/cotp_span.hpp @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include + +namespace ar::iec61850::osi { + +enum class CotpWireKind : std::uint8_t { + unknown, + connection_request, + connection_confirm, + data, + disconnect_request, + error, +}; + +struct CotpParameterView final { + std::uint8_t code{}; + std::span value{}; +}; + +struct CotpTpduView final { + CotpWireKind kind{CotpWireKind::unknown}; + std::uint8_t code{}; + std::uint8_t length_indicator{}; + std::uint16_t destination_reference{}; + std::uint16_t source_reference{}; + std::uint8_t class_or_reason{}; + bool end_of_transmission{}; + std::uint8_t tpdu_number{}; + std::span parameter_bytes{}; + std::span user_data{}; + + [[nodiscard]] bool try_parameter( + std::uint8_t parameter_code, + std::span& value) const noexcept; +}; + +class CotpSpanCodec final { +public: + static constexpr std::uint8_t connection_request_code = 0xE0U; + static constexpr std::uint8_t connection_confirm_code = 0xD0U; + static constexpr std::uint8_t disconnect_request_code = 0x80U; + static constexpr std::uint8_t data_code = 0xF0U; + static constexpr std::uint8_t error_code = 0x70U; + + static constexpr std::uint8_t tpdu_size_parameter = 0xC0U; + static constexpr std::uint8_t source_tsap_parameter = 0xC1U; + static constexpr std::uint8_t destination_tsap_parameter = 0xC2U; + + [[nodiscard]] static CotpWireKind kind_from_code(std::uint8_t code) noexcept; + + // Zero-copy decode. parameter_bytes and user_data point into the supplied + // TPDU storage, which must outlive the returned view. + [[nodiscard]] static bool try_decode_view( + std::span bytes, + CotpTpduView& tpdu) noexcept; + + [[nodiscard]] static bool try_tpdu_size_bytes( + std::uint8_t tpdu_size_code, + std::size_t& bytes) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_data_into( + std::span user_data, + std::span destination, + bool end_of_transmission = true, + std::uint8_t tpdu_number = 0U) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_connection_request_into( + std::uint16_t source_reference, + std::span parameters, + std::span destination, + std::uint16_t destination_reference = 0U, + std::uint8_t transport_class = 0U) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_connection_confirm_into( + std::uint16_t destination_reference, + std::uint16_t source_reference, + std::span destination, + std::uint8_t maximum_tpdu_size_code = 0x0AU) noexcept; + + // Build a server-side CC directly from a zero-copy CR view. The TPDU-size + // offer is negotiated and source/destination TSAP parameters are mirrored + // using the same wire behavior as the host codec, without heap storage. + [[nodiscard]] static wire::EncodeResult encode_connection_confirm_from_request_into( + const CotpTpduView& connection_request, + std::uint16_t source_reference, + std::span destination, + std::uint8_t maximum_tpdu_size_code = 0x0AU) noexcept; +}; + +} // namespace ar::iec61850::osi diff --git a/include/ariec61850/osi/presentation_span.hpp b/include/ariec61850/osi/presentation_span.hpp new file mode 100644 index 000000000..7afb688e1 --- /dev/null +++ b/include/ariec61850/osi/presentation_span.hpp @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::osi { + +struct PresentationPdvView final { + std::uint32_t context_id{}; + std::span single_asn1_type{}; +}; + +struct PresentationCpView final { + std::uint32_t mode_selector{1U}; + std::span calling_selector{}; + std::span called_selector{}; + std::span context_definition_list{}; + std::size_t context_count{}; + PresentationPdvView user_data{}; + + [[nodiscard]] bool try_context_id_for_abstract_syntax( + std::span object_identifier_value, + std::uint32_t& context_id) const noexcept; +}; + +class PresentationSpanCodec final { +public: + static constexpr std::size_t maximum_ppdu_bytes = 1U * 1024U * 1024U; + static constexpr std::size_t maximum_contexts = 64U; + static constexpr std::size_t maximum_oid_bytes = 64U; + static constexpr std::size_t maximum_selector_bytes = 64U; + + [[nodiscard]] static std::span + acse_abstract_syntax_name() noexcept; + [[nodiscard]] static std::span + mms_abstract_syntax_name() noexcept; + [[nodiscard]] static std::span + ber_transfer_syntax_name() noexcept; + + [[nodiscard]] static std::optional fully_encoded_data_size( + std::uint32_t context_id, + std::size_t single_asn1_type_bytes) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_fully_encoded_data_into( + std::uint32_t context_id, + std::span single_asn1_type, + std::span destination) noexcept; + + [[nodiscard]] static bool try_decode_fully_encoded_data_view( + std::span bytes, + PresentationPdvView& pdv) noexcept; + + [[nodiscard]] static bool try_decode_cp_view( + std::span bytes, + PresentationCpView& cp) noexcept; + + [[nodiscard]] static std::optional cpa_accepting_size( + const PresentationCpView& request, + std::size_t acse_aare_bytes) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_cpa_accepting_into( + const PresentationCpView& request, + std::span acse_aare, + std::span destination) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_p_data_into( + std::span abstract_syntax_payload, + std::span destination, + std::uint32_t presentation_context_id = 3U, + bool include_give_tokens_prefix = true) noexcept; + + [[nodiscard]] static bool try_decode_p_data_view( + std::span bytes, + PresentationPdvView& pdv) noexcept; +}; + +} // namespace ar::iec61850::osi diff --git a/include/ariec61850/osi/session_span.hpp b/include/ariec61850/osi/session_span.hpp new file mode 100644 index 000000000..cc0667c90 --- /dev/null +++ b/include/ariec61850/osi/session_span.hpp @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include + +namespace ar::iec61850::osi { + +enum class SessionWireKind : std::uint8_t { + unknown, + connect, + accept, + reject, + refuse, + abort, + data_transfer, +}; + +struct SessionParameterView final { + std::uint8_t code{}; + std::span value{}; +}; + +struct SessionSpduView final { + SessionWireKind kind{SessionWireKind::unknown}; + std::uint8_t code{}; + std::uint8_t length_indicator{}; + std::span parameter_bytes{}; + std::span user_data{}; + + [[nodiscard]] bool try_parameter( + std::uint8_t parameter_code, + std::span& value) const noexcept; +}; + +struct SessionDataTransferView final { + bool has_give_tokens_prefix{}; + std::span presentation_payload{}; +}; + +class SessionSpanCodec final { +public: + static constexpr std::uint8_t connect_code = 0x0DU; + static constexpr std::uint8_t accept_code = 0x0EU; + static constexpr std::uint8_t reject_code = 0x0AU; + static constexpr std::uint8_t refuse_code = 0x0CU; + static constexpr std::uint8_t abort_code = 0x19U; + static constexpr std::uint8_t data_transfer_code = 0x01U; + static constexpr std::uint8_t user_data_parameter = 0xC1U; + static constexpr std::size_t maximum_spdu_bytes = 257U; + static constexpr std::size_t maximum_parameter_count = 64U; + + [[nodiscard]] static SessionWireKind kind_from_code(std::uint8_t code) noexcept; + [[nodiscard]] static std::span default_parameters() noexcept; + + [[nodiscard]] static bool try_decode_prefix_view( + std::span bytes, + SessionSpduView& spdu, + std::size_t& consumed) noexcept; + + [[nodiscard]] static bool try_decode_view( + std::span bytes, + SessionSpduView& spdu) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_into( + std::uint8_t code, + std::span parameters, + std::span user_data, + std::span destination) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_connect_into( + std::span presentation_payload, + std::span destination, + std::span parameters = {}) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_accept_into( + std::span presentation_payload, + std::span destination, + std::span parameters = {}) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_accept_mirroring_into( + const SessionSpduView& connect, + std::span presentation_payload, + std::span destination) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_data_transfer_into( + std::span presentation_payload, + std::span destination, + bool include_give_tokens_prefix = true) noexcept; + + [[nodiscard]] static bool try_decode_data_transfer_view( + std::span bytes, + SessionDataTransferView& transfer) noexcept; +}; + +} // namespace ar::iec61850::osi diff --git a/include/ariec61850/osi/tpkt_span.hpp b/include/ariec61850/osi/tpkt_span.hpp new file mode 100644 index 000000000..07327ebe8 --- /dev/null +++ b/include/ariec61850/osi/tpkt_span.hpp @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include + +namespace ar::iec61850::osi { + +enum class TpktPeekStatus : std::uint8_t { + need_more, + ready, + invalid, +}; + +struct TpktPeekResult final { + TpktPeekStatus status{TpktPeekStatus::need_more}; + std::size_t frame_bytes{}; + + [[nodiscard]] constexpr bool ready() const noexcept { + return status == TpktPeekStatus::ready; + } +}; + +struct TpktFrameView final { + std::uint8_t version{}; + std::uint16_t declared_length{}; + std::span payload{}; +}; + +class TpktSpanCodec final { +public: + static constexpr std::uint8_t supported_version = 0x03U; + static constexpr std::size_t header_length = 4U; + static constexpr std::size_t maximum_frame_bytes = 65'535U; + static constexpr std::size_t maximum_payload_bytes = + maximum_frame_bytes - header_length; + + [[nodiscard]] static wire::EncodeResult encode_into( + std::span payload, + std::span destination) noexcept; + + // Inspect the first frame in a TCP receive window without copying it. + // `ready` may be returned even when bytes contains coalesced frames; + // frame_bytes identifies the exact prefix belonging to the first TPKT. + [[nodiscard]] static TpktPeekResult peek_frame( + std::span bytes) noexcept; + + // Decode one exact TPKT frame into non-owning spans. The supplied storage + // must outlive the returned view. + [[nodiscard]] static bool try_decode_view( + std::span bytes, + TpktFrameView& frame) noexcept; +}; + +} // namespace ar::iec61850::osi diff --git a/include/ariec61850/sampled_values/deterministic_injector.hpp b/include/ariec61850/sampled_values/deterministic_injector.hpp new file mode 100644 index 000000000..87fe27a56 --- /dev/null +++ b/include/ariec61850/sampled_values/deterministic_injector.hpp @@ -0,0 +1,358 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +inline constexpr std::size_t injector_channel_count = 8U; +inline constexpr std::uint32_t injector_gain_one = 10'000U; + +struct InjectorSignalProfile final { + bool enabled{true}; + std::int32_t rms_counts{}; + std::int32_t dc_counts{}; + std::int32_t phase_millidegrees{}; + std::uint32_t frequency_millihz{50'000U}; + std::uint16_t harmonic_permyriad{}; + std::uint8_t harmonic_order{2U}; + std::uint16_t clip_permyriad{}; + std::uint32_t quality{}; +}; + +enum class InjectorSegmentTransition : std::uint8_t { + step, + linear_from_previous, +}; + +struct InjectorScenarioSegment final { + // Zero means hold this segment indefinitely. + std::uint32_t duration_samples{}; + InjectorSegmentTransition transition{InjectorSegmentTransition::step}; + std::array channels{}; +}; + +struct InjectorSample final { + std::uint64_t sample_index{}; + std::size_t segment_index{}; + std::array values{}; + std::array qualities{}; +}; + +class DeterministicSvInjector final { +public: + DeterministicSvInjector( + const std::span segments, + const std::uint32_t sample_rate_hz, + const bool loop) noexcept + : segments_(segments), sample_rate_hz_(sample_rate_hz), loop_(loop) { + valid_ = validate_configuration(); + reset(); + } + + [[nodiscard]] bool valid() const noexcept { return valid_; } + [[nodiscard]] bool finished() const noexcept { return finished_; } + [[nodiscard]] std::uint64_t sample_index() const noexcept { return sample_index_; } + [[nodiscard]] std::size_t segment_index() const noexcept { return segment_index_; } + + void reset() noexcept { + sample_index_ = 0U; + segment_index_ = 0U; + sample_in_segment_ = 0U; + finished_ = !valid_; + phase_remainder_.fill(0U); + phase_.fill(0U); + if (!valid_) { + return; + } + for (std::size_t channel = 0U; channel < injector_channel_count; ++channel) { + phase_[channel] = phase_from_millidegrees( + segments_.front().channels[channel].phase_millidegrees); + } + } + + [[nodiscard]] bool step(InjectorSample& output) noexcept { + if (!valid_ || finished_) { + return false; + } + + output.sample_index = sample_index_; + output.segment_index = segment_index_; + + for (std::size_t channel = 0U; channel < injector_channel_count; ++channel) { + const auto profile = effective_profile(channel); + output.values[channel] = evaluate_channel(profile, channel); + output.qualities[channel] = profile.quality; + advance_phase(profile.frequency_millihz, channel); + } + + ++sample_index_; + advance_segment(); + return true; + } + +private: + static constexpr std::uint64_t phase_turn = 0x1'0000'0000ULL; + static constexpr std::int64_t sqrt_two_q30 = 1'518'500'250LL; + static constexpr std::array sine_q15_table{ + 0, 804, 1608, 2410, 3212, 4011, 4808, 5602, 6393, 7179, 7962, 8739, 9512, 10278, 11039, 11793, + 12539, 13279, 14010, 14732, 15446, 16151, 16846, 17530, 18204, 18868, 19519, 20159, 20787, 21403, 22005, 22594, + 23170, 23731, 24279, 24811, 25329, 25832, 26319, 26790, 27245, 27683, 28105, 28510, 28898, 29268, 29621, 29956, + 30273, 30571, 30852, 31113, 31356, 31580, 31785, 31971, 32137, 32285, 32412, 32521, 32609, 32678, 32728, 32757, + 32767, 32757, 32728, 32678, 32609, 32521, 32412, 32285, 32137, 31971, 31785, 31580, 31356, 31113, 30852, 30571, + 30273, 29956, 29621, 29268, 28898, 28510, 28105, 27683, 27245, 26790, 26319, 25832, 25329, 24811, 24279, 23731, + 23170, 22594, 22005, 21403, 20787, 20159, 19519, 18868, 18204, 17530, 16846, 16151, 15446, 14732, 14010, 13279, + 12539, 11793, 11039, 10278, 9512, 8739, 7962, 7179, 6393, 5602, 4808, 4011, 3212, 2410, 1608, 804, + 0, -804, -1608, -2410, -3212, -4011, -4808, -5602, -6393, -7179, -7962, -8739, -9512, -10278, -11039, -11793, + -12539, -13279, -14010, -14732, -15446, -16151, -16846, -17530, -18204, -18868, -19519, -20159, -20787, -21403, -22005, -22594, + -23170, -23731, -24279, -24811, -25329, -25832, -26319, -26790, -27245, -27683, -28105, -28510, -28898, -29268, -29621, -29956, + -30273, -30571, -30852, -31113, -31356, -31580, -31785, -31971, -32137, -32285, -32412, -32521, -32609, -32678, -32728, -32757, + -32767, -32757, -32728, -32678, -32609, -32521, -32412, -32285, -32137, -31971, -31785, -31580, -31356, -31113, -30852, -30571, + -30273, -29956, -29621, -29268, -28898, -28510, -28105, -27683, -27245, -26790, -26319, -25832, -25329, -24811, -24279, -23731, + -23170, -22594, -22005, -21403, -20787, -20159, -19519, -18868, -18204, -17530, -16846, -16151, -15446, -14732, -14010, -13279, + -12539, -11793, -11039, -10278, -9512, -8739, -7962, -7179, -6393, -5602, -4808, -4011, -3212, -2410, -1608, -804, + }; + + [[nodiscard]] bool validate_configuration() const noexcept { + if (segments_.empty() || sample_rate_hz_ == 0U || sample_rate_hz_ > 1'000'000U) { + return false; + } + const auto maximum_frequency_millihz = + static_cast(sample_rate_hz_) * 500ULL; + for (const auto& segment : segments_) { + for (const auto& channel : segment.channels) { + if (static_cast(channel.frequency_millihz) > + maximum_frequency_millihz || + channel.harmonic_permyriad > injector_gain_one || + channel.harmonic_order < 2U || + channel.harmonic_order > 63U || + channel.clip_permyriad > injector_gain_one) { + return false; + } + } + } + return true; + } + + [[nodiscard]] static std::uint32_t phase_from_millidegrees( + const std::int32_t millidegrees) noexcept { + constexpr std::int64_t full_circle = 360'000LL; + auto normalized = static_cast(millidegrees) % full_circle; + if (normalized < 0LL) { + normalized += full_circle; + } + const auto numerator = static_cast(normalized) * phase_turn; + return static_cast( + numerator / static_cast(full_circle)); + } + + [[nodiscard]] static std::int32_t sine_q15(const std::uint32_t phase) noexcept { + const auto index = static_cast(phase >> 24U); + const auto next = static_cast( + index + static_cast(1U)); + const auto fraction = static_cast((phase >> 8U) & 0xFFFFU); + const auto left = static_cast(sine_q15_table[index]); + const auto right = static_cast(sine_q15_table[next]); + const auto delta = static_cast(right - left); + const auto interpolated = static_cast(left) + + (delta * static_cast(fraction)) / 65'536LL; + return static_cast(interpolated); + } + + [[nodiscard]] static std::int32_t interpolate_i32( + const std::int32_t start, + const std::int32_t finish, + const std::uint32_t position, + const std::uint32_t denominator) noexcept { + if (denominator == 0U || start == finish) { + return finish; + } + const auto delta = static_cast(finish) - + static_cast(start); + const auto value = static_cast(start) + + (delta * static_cast(position)) / + static_cast(denominator); + return clamp_i32(value); + } + + [[nodiscard]] static std::uint32_t interpolate_u32( + const std::uint32_t start, + const std::uint32_t finish, + const std::uint32_t position, + const std::uint32_t denominator) noexcept { + if (denominator == 0U || start == finish) { + return finish; + } + const auto delta = static_cast(finish) - + static_cast(start); + const auto value = static_cast(start) + + (delta * static_cast(position)) / + static_cast(denominator); + return value <= 0LL ? 0U : static_cast(value); + } + + [[nodiscard]] InjectorSignalProfile effective_profile( + const std::size_t channel) const noexcept { + auto profile = segments_[segment_index_].channels[channel]; + const auto& segment = segments_[segment_index_]; + if (segment.transition != InjectorSegmentTransition::linear_from_previous || + segment_index_ == 0U || segment.duration_samples <= 1U) { + return profile; + } + + const auto& previous = segments_[segment_index_ - 1U].channels[channel]; + const auto denominator = segment.duration_samples - 1U; + profile.rms_counts = interpolate_i32( + previous.rms_counts, profile.rms_counts, sample_in_segment_, denominator); + profile.dc_counts = interpolate_i32( + previous.dc_counts, profile.dc_counts, sample_in_segment_, denominator); + profile.frequency_millihz = interpolate_u32( + previous.frequency_millihz, + profile.frequency_millihz, + sample_in_segment_, + denominator); + profile.harmonic_permyriad = static_cast(interpolate_u32( + previous.harmonic_permyriad, + profile.harmonic_permyriad, + sample_in_segment_, + denominator)); + profile.clip_permyriad = static_cast(interpolate_u32( + previous.clip_permyriad, + profile.clip_permyriad, + sample_in_segment_, + denominator)); + return profile; + } + + [[nodiscard]] static std::int32_t clamp_i32(const std::int64_t value) noexcept { + if (value > static_cast( + std::numeric_limits::max())) { + return std::numeric_limits::max(); + } + if (value < static_cast( + std::numeric_limits::min())) { + return std::numeric_limits::min(); + } + return static_cast(value); + } + + [[nodiscard]] std::int32_t evaluate_channel( + const InjectorSignalProfile& profile, + const std::size_t channel) const noexcept { + if (!profile.enabled || profile.rms_counts == 0) { + return profile.dc_counts; + } + + const auto peak = + (static_cast(profile.rms_counts) * sqrt_two_q30) >> 30U; + const auto fundamental_q15 = sine_q15(phase_[channel]); + auto sample = + (peak * static_cast(fundamental_q15)) / 32'767LL; + + if (profile.harmonic_permyriad > 0U) { + const auto harmonic_phase = static_cast( + static_cast(phase_[channel]) * + static_cast(profile.harmonic_order)); + const auto harmonic_q15 = sine_q15(harmonic_phase); + const auto harmonic_peak = + (peak * static_cast(profile.harmonic_permyriad)) / + static_cast(injector_gain_one); + sample += + (harmonic_peak * static_cast(harmonic_q15)) / 32'767LL; + } + + sample += static_cast(profile.dc_counts); + + if (profile.clip_permyriad > 0U) { + const auto absolute_peak = peak < 0LL ? -peak : peak; + const auto limit = + (absolute_peak * static_cast(profile.clip_permyriad)) / + static_cast(injector_gain_one); + if (sample > limit) { + sample = limit; + } else if (sample < -limit) { + sample = -limit; + } + } + + return clamp_i32(sample); + } + + void advance_phase( + const std::uint32_t frequency_millihz, + const std::size_t channel) noexcept { + const auto denominator = + static_cast(sample_rate_hz_) * 1'000ULL; + const auto numerator = + static_cast(frequency_millihz) * phase_turn + + phase_remainder_[channel]; + const auto increment = numerator / denominator; + phase_remainder_[channel] = numerator % denominator; + phase_[channel] += static_cast(increment); + } + + void adjust_phase_offsets( + const InjectorScenarioSegment& previous, + const InjectorScenarioSegment& next) noexcept { + for (std::size_t channel = 0U; channel < injector_channel_count; ++channel) { + const auto delta = + static_cast(next.channels[channel].phase_millidegrees) - + static_cast(previous.channels[channel].phase_millidegrees); + auto normalized = delta % 360'000LL; + if (normalized < 0LL) { + normalized += 360'000LL; + } + const auto delta_phase = static_cast( + (static_cast(normalized) * phase_turn) / 360'000ULL); + phase_[channel] += delta_phase; + } + } + + void advance_segment() noexcept { + const auto duration = segments_[segment_index_].duration_samples; + if (duration == 0U) { + ++sample_in_segment_; + return; + } + + ++sample_in_segment_; + if (sample_in_segment_ < duration) { + return; + } + + const auto previous_index = segment_index_; + sample_in_segment_ = 0U; + if (segment_index_ + 1U < segments_.size()) { + ++segment_index_; + adjust_phase_offsets( + segments_[previous_index], segments_[segment_index_]); + return; + } + + if (loop_) { + segment_index_ = 0U; + adjust_phase_offsets( + segments_[previous_index], segments_[segment_index_]); + return; + } + + finished_ = true; + } + + std::span segments_{}; + std::uint32_t sample_rate_hz_{}; + bool loop_{}; + bool valid_{}; + bool finished_{}; + std::uint64_t sample_index_{}; + std::size_t segment_index_{}; + std::uint32_t sample_in_segment_{}; + std::array phase_{}; + std::array phase_remainder_{}; +}; + +} // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/sampled_values/frame_codec.hpp b/include/ariec61850/sampled_values/frame_codec.hpp index ba9ddce0d..44bd774d0 100644 --- a/include/ariec61850/sampled_values/frame_codec.hpp +++ b/include/ariec61850/sampled_values/frame_codec.hpp @@ -9,7 +9,9 @@ #include #include #include +#if !defined(ARIEC61850_NO_EXCEPTIONS) #include +#endif namespace ar::iec61850::sampled_values { @@ -22,12 +24,15 @@ class SampledValuesFrameCodec final { const SampledValuesFrame& frame, std::span destination) noexcept; - // Host convenience wrapper. Embedded SV publishers should use encode_into - // with a persistent Ethernet-sized buffer. +#if !defined(ARIEC61850_NO_EXCEPTIONS) + // Host/soft-profile convenience wrapper. Hard embedded publishers should + // use encode_into with persistent caller-owned Ethernet storage. [[nodiscard]] static std::vector encode(const SampledValuesFrame& frame); + // Dynamic receive-side decode remains outside the first hard profile. [[nodiscard]] static bool try_decode( std::span bytes, SampledValuesFrame& frame) noexcept; +#endif }; } // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/sampled_values/injector_control_protocol.hpp b/include/ariec61850/sampled_values/injector_control_protocol.hpp new file mode 100644 index 000000000..1bf4716d8 --- /dev/null +++ b/include/ariec61850/sampled_values/injector_control_protocol.hpp @@ -0,0 +1,480 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/injector_controller.hpp" +#include "ariec61850/sampled_values/injector_runtime_program.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +enum class InjectorControlCommandKind : std::uint8_t { + capabilities, + configure, + arm, + start, + stop, + status, + stats, + set_channel, + ramp_channel, + sequence_begin, + sequence_state_begin, + sequence_set_channel, + sequence_state_commit, + sequence_commit, + sequence_abort, +}; + +enum class InjectorControlParseStatus : std::uint8_t { + ok, + malformed, + missing_command, + unsupported_command, + missing_scenario, + unsupported_scenario, + missing_channel, + unsupported_channel, + missing_value, + invalid_value, + unsupported_transition, +}; + +struct InjectorControlCommand final { + InjectorControlCommandKind kind{InjectorControlCommandKind::status}; + InjectorScenarioKind scenario{InjectorScenarioKind::normal}; + InjectorChannelEdit channel_edit{}; + std::uint32_t duration_samples{}; + InjectorSegmentTransition transition{InjectorSegmentTransition::step}; +}; + +struct InjectorControlParseResult final { + InjectorControlParseStatus status{InjectorControlParseStatus::malformed}; + InjectorControlCommand command{}; + + [[nodiscard]] constexpr bool success() const noexcept { + return status == InjectorControlParseStatus::ok; + } +}; + +namespace detail { + +enum class JsonIntegerStatus : std::uint8_t { + missing, + ok, + malformed, +}; + +[[nodiscard]] inline std::string_view trim_ascii( + std::string_view value) noexcept { + while (!value.empty() && + std::isspace(static_cast(value.front())) != 0) { + value.remove_prefix(1U); + } + while (!value.empty() && + std::isspace(static_cast(value.back())) != 0) { + value.remove_suffix(1U); + } + return value; +} + +[[nodiscard]] inline bool find_json_value_cursor( + const std::string_view object, + const std::string_view key, + std::size_t& cursor) noexcept { + const auto quoted_key_length = key.size() + 2U; + if (quoted_key_length > object.size()) { + return false; + } + + std::size_t position = 0U; + while (position + quoted_key_length <= object.size()) { + const auto quote = object.find('"', position); + if (quote == std::string_view::npos || + quote + quoted_key_length > object.size()) { + return false; + } + if (object.substr(quote + 1U, key.size()) != key || + object[quote + 1U + key.size()] != '"') { + position = quote + 1U; + continue; + } + + cursor = quote + quoted_key_length; + while (cursor < object.size() && + std::isspace(static_cast(object[cursor])) != 0) { + ++cursor; + } + if (cursor >= object.size() || object[cursor] != ':') { + return false; + } + ++cursor; + while (cursor < object.size() && + std::isspace(static_cast(object[cursor])) != 0) { + ++cursor; + } + return cursor < object.size(); + } + return false; +} + +[[nodiscard]] inline bool json_string_value( + const std::string_view object, + const std::string_view key, + std::string_view& output) noexcept { + std::size_t cursor{}; + if (!find_json_value_cursor(object, key, cursor) || object[cursor] != '"') { + return false; + } + ++cursor; + + const auto end = object.find('"', cursor); + if (end == std::string_view::npos) { + return false; + } + if (object.substr(cursor, end - cursor).find('\\') != + std::string_view::npos) { + return false; + } + output = object.substr(cursor, end - cursor); + return true; +} + +[[nodiscard]] inline JsonIntegerStatus json_integer_value( + const std::string_view object, + const std::string_view key, + std::int64_t& output) noexcept { + std::size_t cursor{}; + if (!find_json_value_cursor(object, key, cursor)) { + return JsonIntegerStatus::missing; + } + + bool negative = false; + if (object[cursor] == '-') { + negative = true; + ++cursor; + } + if (cursor >= object.size() || + std::isdigit(static_cast(object[cursor])) == 0) { + return JsonIntegerStatus::malformed; + } + + std::uint64_t magnitude{}; + constexpr auto positive_limit = + static_cast(std::numeric_limits::max()); + constexpr auto negative_limit = positive_limit + 1ULL; + const auto limit = negative ? negative_limit : positive_limit; + + while (cursor < object.size() && + std::isdigit(static_cast(object[cursor])) != 0) { + const auto digit = static_cast(object[cursor] - '0'); + if (magnitude > (limit - digit) / 10ULL) { + return JsonIntegerStatus::malformed; + } + magnitude = magnitude * 10ULL + digit; + ++cursor; + } + + while (cursor < object.size() && + std::isspace(static_cast(object[cursor])) != 0) { + ++cursor; + } + if (cursor < object.size() && object[cursor] != ',' && object[cursor] != '}') { + return JsonIntegerStatus::malformed; + } + + if (negative) { + if (magnitude == negative_limit) { + output = std::numeric_limits::min(); + } else { + output = -static_cast(magnitude); + } + } else { + output = static_cast(magnitude); + } + return JsonIntegerStatus::ok; +} + +[[nodiscard]] inline bool parse_channel_index( + const std::string_view name, + std::uint8_t& index) noexcept { + constexpr std::string_view names[injector_channel_count]{ + "Ia", "Ib", "Ic", "In", "Va", "Vb", "Vc", "Vn"}; + for (std::size_t candidate = 0U; candidate < injector_channel_count; ++candidate) { + if (name == names[candidate]) { + index = static_cast(candidate); + return true; + } + } + return false; +} + +[[nodiscard]] inline InjectorControlParseStatus parse_channel_edit( + const std::string_view line, + InjectorChannelEdit& edit, + const bool require_duration) noexcept { + std::string_view channel_name; + if (!json_string_value(line, "channel", channel_name)) { + return InjectorControlParseStatus::missing_channel; + } + if (!parse_channel_index(channel_name, edit.channel_index)) { + return InjectorControlParseStatus::unsupported_channel; + } + + auto parse_signed = [&](const std::string_view key, + const std::uint16_t bit, + std::int32_t& target) noexcept -> bool { + std::int64_t value{}; + const auto status = json_integer_value(line, key, value); + if (status == JsonIntegerStatus::missing) { + return true; + } + if (status != JsonIntegerStatus::ok || + value < std::numeric_limits::min() || + value > std::numeric_limits::max()) { + return false; + } + target = static_cast(value); + edit.fields = static_cast(edit.fields | bit); + return true; + }; + + auto parse_unsigned = [&](const std::string_view key, + const std::uint16_t bit, + const std::uint64_t maximum, + std::uint32_t& target) noexcept -> bool { + std::int64_t value{}; + const auto status = json_integer_value(line, key, value); + if (status == JsonIntegerStatus::missing) { + return true; + } + if (status != JsonIntegerStatus::ok || value < 0 || + static_cast(value) > maximum) { + return false; + } + target = static_cast(value); + edit.fields = static_cast(edit.fields | bit); + return true; + }; + + std::int64_t enabled{}; + const auto enabled_status = json_integer_value(line, "enabled", enabled); + if (enabled_status == JsonIntegerStatus::malformed || + (enabled_status == JsonIntegerStatus::ok && enabled != 0 && enabled != 1)) { + return InjectorControlParseStatus::invalid_value; + } + if (enabled_status == JsonIntegerStatus::ok) { + edit.enabled = enabled != 0; + edit.fields = static_cast( + edit.fields | injector_field_enabled); + } + + if (!parse_signed("rms", injector_field_rms, edit.rms_counts) || + !parse_signed("dc", injector_field_dc, edit.dc_counts) || + !parse_signed("phaseMilliDeg", injector_field_phase, edit.phase_millidegrees)) { + return InjectorControlParseStatus::invalid_value; + } + + std::uint32_t frequency{}; + if (!parse_unsigned( + "frequencyMilliHz", + injector_field_frequency, + 2'000'000ULL, + frequency)) { + return InjectorControlParseStatus::invalid_value; + } + if ((edit.fields & injector_field_frequency) != 0U) { + edit.frequency_millihz = frequency; + } + + std::uint32_t harmonic{}; + if (!parse_unsigned( + "harmonicPermyriad", + injector_field_harmonic, + injector_gain_one, + harmonic)) { + return InjectorControlParseStatus::invalid_value; + } + if ((edit.fields & injector_field_harmonic) != 0U) { + edit.harmonic_permyriad = static_cast(harmonic); + } + + std::uint32_t harmonic_order{}; + if (!parse_unsigned( + "harmonicOrder", + injector_field_harmonic_order, + 63U, + harmonic_order)) { + return InjectorControlParseStatus::invalid_value; + } + if ((edit.fields & injector_field_harmonic_order) != 0U) { + if (harmonic_order < 2U) { + return InjectorControlParseStatus::invalid_value; + } + edit.harmonic_order = static_cast(harmonic_order); + } + + std::uint32_t clip{}; + if (!parse_unsigned( + "clipPermyriad", + injector_field_clip, + injector_gain_one, + clip)) { + return InjectorControlParseStatus::invalid_value; + } + if ((edit.fields & injector_field_clip) != 0U) { + edit.clip_permyriad = static_cast(clip); + } + + std::uint32_t quality{}; + if (!parse_unsigned( + "quality", + injector_field_quality, + std::numeric_limits::max(), + quality)) { + return InjectorControlParseStatus::invalid_value; + } + if ((edit.fields & injector_field_quality) != 0U) { + edit.quality = quality; + } + + if (edit.fields == 0U) { + return InjectorControlParseStatus::missing_value; + } + + if (require_duration) { + std::int64_t duration{}; + const auto duration_status = + json_integer_value(line, "durationSamples", duration); + if (duration_status == JsonIntegerStatus::missing) { + return InjectorControlParseStatus::missing_value; + } + if (duration_status != JsonIntegerStatus::ok || duration < 2 || + duration > 100'000'000LL) { + return InjectorControlParseStatus::invalid_value; + } + edit.duration_samples = static_cast(duration); + } + + return InjectorControlParseStatus::ok; +} + +[[nodiscard]] inline InjectorControlParseStatus parse_sequence_state_begin( + const std::string_view line, + InjectorControlCommand& command) noexcept { + std::int64_t duration{}; + const auto duration_status = json_integer_value(line, "durationSamples", duration); + if (duration_status == JsonIntegerStatus::missing) { + return InjectorControlParseStatus::missing_value; + } + if (duration_status != JsonIntegerStatus::ok || duration < 1 || + duration > 100'000'000LL) { + return InjectorControlParseStatus::invalid_value; + } + command.duration_samples = static_cast(duration); + + std::string_view transition; + if (!json_string_value(line, "transition", transition)) { + command.transition = InjectorSegmentTransition::step; + return InjectorControlParseStatus::ok; + } + if (transition == "step") { + command.transition = InjectorSegmentTransition::step; + } else if (transition == "linear") { + command.transition = InjectorSegmentTransition::linear_from_previous; + } else { + return InjectorControlParseStatus::unsupported_transition; + } + return InjectorControlParseStatus::ok; +} + +} // namespace detail + +// Parses the intentionally narrow control-plane JSON subset used on MCU serial +// links. Each command is one JSON object on one line. Escaped string values, +// nested objects, arrays, and arbitrary JSON are deliberately outside v1. +[[nodiscard]] inline InjectorControlParseResult parse_injector_control_command( + std::string_view line) noexcept { + line = detail::trim_ascii(line); + if (line.size() < 2U || line.front() != '{' || line.back() != '}') { + return {InjectorControlParseStatus::malformed, {}}; + } + + std::string_view command_name; + if (!detail::json_string_value(line, "command", command_name)) { + return {InjectorControlParseStatus::missing_command, {}}; + } + + InjectorControlCommand command{}; + if (command_name == "capabilities") { + command.kind = InjectorControlCommandKind::capabilities; + } else if (command_name == "configure") { + command.kind = InjectorControlCommandKind::configure; + std::string_view scenario_name; + if (!detail::json_string_value(line, "scenario", scenario_name)) { + return {InjectorControlParseStatus::missing_scenario, {}}; + } + if (scenario_name == "normal") { + command.scenario = InjectorScenarioKind::normal; + } else if (scenario_name == "protection-fault") { + command.scenario = InjectorScenarioKind::protection_fault; + } else { + return {InjectorControlParseStatus::unsupported_scenario, {}}; + } + } else if (command_name == "arm") { + command.kind = InjectorControlCommandKind::arm; + } else if (command_name == "start") { + command.kind = InjectorControlCommandKind::start; + } else if (command_name == "stop") { + command.kind = InjectorControlCommandKind::stop; + } else if (command_name == "status") { + command.kind = InjectorControlCommandKind::status; + } else if (command_name == "stats") { + command.kind = InjectorControlCommandKind::stats; + } else if (command_name == "set-channel") { + command.kind = InjectorControlCommandKind::set_channel; + const auto status = detail::parse_channel_edit( + line, command.channel_edit, false); + if (status != InjectorControlParseStatus::ok) { + return {status, {}}; + } + } else if (command_name == "ramp-channel") { + command.kind = InjectorControlCommandKind::ramp_channel; + const auto status = detail::parse_channel_edit( + line, command.channel_edit, true); + if (status != InjectorControlParseStatus::ok) { + return {status, {}}; + } + } else if (command_name == "sequence-begin") { + command.kind = InjectorControlCommandKind::sequence_begin; + } else if (command_name == "sequence-state-begin") { + command.kind = InjectorControlCommandKind::sequence_state_begin; + const auto status = detail::parse_sequence_state_begin(line, command); + if (status != InjectorControlParseStatus::ok) { + return {status, {}}; + } + } else if (command_name == "sequence-set-channel") { + command.kind = InjectorControlCommandKind::sequence_set_channel; + const auto status = detail::parse_channel_edit( + line, command.channel_edit, false); + if (status != InjectorControlParseStatus::ok) { + return {status, {}}; + } + } else if (command_name == "sequence-state-commit") { + command.kind = InjectorControlCommandKind::sequence_state_commit; + } else if (command_name == "sequence-commit") { + command.kind = InjectorControlCommandKind::sequence_commit; + } else if (command_name == "sequence-abort") { + command.kind = InjectorControlCommandKind::sequence_abort; + } else { + return {InjectorControlParseStatus::unsupported_command, {}}; + } + + return {InjectorControlParseStatus::ok, command}; +} + +} // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/sampled_values/injector_controller.hpp b/include/ariec61850/sampled_values/injector_controller.hpp new file mode 100644 index 000000000..52ae91a91 --- /dev/null +++ b/include/ariec61850/sampled_values/injector_controller.hpp @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include + +namespace ar::iec61850::sampled_values { + +enum class InjectorScenarioKind : std::uint8_t { + normal, + protection_fault, +}; + +enum class InjectorControlState : std::uint8_t { + idle, + configured, + armed, + running, + stopped, + fault, +}; + +enum class InjectorControlStatus : std::uint8_t { + ok, + invalid_state, + invalid_configuration, +}; + +struct InjectorControlConfig final { + std::uint32_t sample_rate_hz{4'000U}; + InjectorScenarioKind scenario{InjectorScenarioKind::normal}; +}; + +struct InjectorControlSnapshot final { + InjectorControlState state{InjectorControlState::idle}; + InjectorControlConfig configuration{}; + std::uint32_t configuration_revision{}; + std::uint32_t armed_revision{}; + std::uint64_t run_sequence{}; +}; + +// Transport-independent injector lifecycle state machine. +// +// JSON, USB serial, TCP, GUI, and ESP-IDF console code must stay outside this +// class. They translate commands into these operations so Windows and embedded +// targets share the same lifecycle semantics. +class InjectorController final { +public: + [[nodiscard]] InjectorControlSnapshot snapshot() const noexcept { + return { + state_, + configuration_, + configuration_revision_, + armed_revision_, + run_sequence_}; + } + + [[nodiscard]] InjectorControlState state() const noexcept { return state_; } + [[nodiscard]] bool running() const noexcept { + return state_ == InjectorControlState::running; + } + + [[nodiscard]] InjectorControlStatus configure( + const InjectorControlConfig configuration) noexcept { + if (state_ == InjectorControlState::armed || + state_ == InjectorControlState::running) { + return InjectorControlStatus::invalid_state; + } + if (!valid(configuration)) { + return InjectorControlStatus::invalid_configuration; + } + + configuration_ = configuration; + ++configuration_revision_; + if (configuration_revision_ == 0U) { + ++configuration_revision_; + } + armed_revision_ = 0U; + state_ = InjectorControlState::configured; + return InjectorControlStatus::ok; + } + + [[nodiscard]] InjectorControlStatus arm() noexcept { + if (state_ != InjectorControlState::configured && + state_ != InjectorControlState::stopped) { + return InjectorControlStatus::invalid_state; + } + if (configuration_revision_ == 0U) { + return InjectorControlStatus::invalid_configuration; + } + + armed_revision_ = configuration_revision_; + state_ = InjectorControlState::armed; + return InjectorControlStatus::ok; + } + + [[nodiscard]] InjectorControlStatus start() noexcept { + if (state_ != InjectorControlState::armed || + armed_revision_ == 0U || + armed_revision_ != configuration_revision_) { + return InjectorControlStatus::invalid_state; + } + + ++run_sequence_; + if (run_sequence_ == 0U) { + ++run_sequence_; + } + state_ = InjectorControlState::running; + return InjectorControlStatus::ok; + } + + [[nodiscard]] InjectorControlStatus stop() noexcept { + if (state_ != InjectorControlState::armed && + state_ != InjectorControlState::running) { + return InjectorControlStatus::invalid_state; + } + state_ = InjectorControlState::stopped; + return InjectorControlStatus::ok; + } + + void set_fault() noexcept { + state_ = InjectorControlState::fault; + } + +private: + [[nodiscard]] static bool valid( + const InjectorControlConfig configuration) noexcept { + return configuration.sample_rate_hz > 0U && + configuration.sample_rate_hz <= 1'000'000U; + } + + InjectorControlConfig configuration_{}; + std::uint32_t configuration_revision_{}; + std::uint32_t armed_revision_{}; + std::uint64_t run_sequence_{}; + InjectorControlState state_{InjectorControlState::idle}; +}; + +} // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/sampled_values/injector_presets.hpp b/include/ariec61850/sampled_values/injector_presets.hpp new file mode 100644 index 000000000..18c2df8e3 --- /dev/null +++ b/include/ariec61850/sampled_values/injector_presets.hpp @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/deterministic_injector.hpp" + +#include +#include + +namespace ar::iec61850::sampled_values { + +// Canonical first-trial channel order shared by Windows host simulation and the +// ESP32-P4 injector application: Ia, Ib, Ic, In, Va, Vb, Vc, Vn. +[[nodiscard]] inline std::array +make_balanced_4i4v_profile( + const std::int32_t current_rms_counts = 1'000, + const std::int32_t voltage_rms_counts = 5'774, + const std::uint32_t frequency_millihz = 50'000U, + const std::uint32_t quality = 0U) noexcept { + std::array channels{}; + + channels[0] = {true, current_rms_counts, 0, 0, frequency_millihz, 0U, 2U, 0U, quality}; + channels[1] = {true, current_rms_counts, 0, -120'000, frequency_millihz, 0U, 2U, 0U, quality}; + channels[2] = {true, current_rms_counts, 0, 120'000, frequency_millihz, 0U, 2U, 0U, quality}; + channels[3] = {false, 0, 0, 0, frequency_millihz, 0U, 2U, 0U, quality}; + + channels[4] = {true, voltage_rms_counts, 0, 0, frequency_millihz, 0U, 2U, 0U, quality}; + channels[5] = {true, voltage_rms_counts, 0, -120'000, frequency_millihz, 0U, 2U, 0U, quality}; + channels[6] = {true, voltage_rms_counts, 0, 120'000, frequency_millihz, 0U, 2U, 0U, quality}; + channels[7] = {false, 0, 0, 0, frequency_millihz, 0U, 2U, 0U, quality}; + + return channels; +} + +[[nodiscard]] inline InjectorScenarioSegment make_hold_segment( + const std::array& channels, + const std::uint32_t duration_samples = 0U) noexcept { + return { + duration_samples, + InjectorSegmentTransition::step, + channels}; +} + +} // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/sampled_values/injector_profile_builder.hpp b/include/ariec61850/sampled_values/injector_profile_builder.hpp new file mode 100644 index 000000000..0f5f776da --- /dev/null +++ b/include/ariec61850/sampled_values/injector_profile_builder.hpp @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/injector_runtime_program.hpp" + +#include + +namespace ar::iec61850::sampled_values { + +class InjectorProfileBuilder final { +public: + using ChannelProfiles = InjectorRuntimeProgram::ChannelProfiles; + + void begin( + const ChannelProfiles& base, + const std::uint32_t source_revision, + const std::uint32_t ramp_duration_samples = 0U) noexcept { + target_ = base; + source_revision_ = source_revision; + ramp_duration_samples_ = ramp_duration_samples; + active_ = ramp_duration_samples == 0U || ramp_duration_samples >= 2U; + } + + void abort() noexcept { + active_ = false; + source_revision_ = 0U; + ramp_duration_samples_ = 0U; + } + + [[nodiscard]] bool active() const noexcept { return active_; } + [[nodiscard]] bool ramp() const noexcept { + return active_ && ramp_duration_samples_ >= 2U; + } + [[nodiscard]] std::uint32_t source_revision() const noexcept { + return source_revision_; + } + [[nodiscard]] std::uint32_t ramp_duration_samples() const noexcept { + return ramp_duration_samples_; + } + [[nodiscard]] const ChannelProfiles& target() const noexcept { return target_; } + + [[nodiscard]] bool edit_channel(const InjectorChannelEdit& edit) noexcept { + return active_ && InjectorRuntimeProgram::apply_edit(target_, edit); + } + +private: + ChannelProfiles target_{}; + std::uint32_t source_revision_{}; + std::uint32_t ramp_duration_samples_{}; + bool active_{}; +}; + +} // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/sampled_values/injector_replay_ring.hpp b/include/ariec61850/sampled_values/injector_replay_ring.hpp new file mode 100644 index 000000000..628653931 --- /dev/null +++ b/include/ariec61850/sampled_values/injector_replay_ring.hpp @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/replay_bundle.hpp" + +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +struct InjectorReplayStatistics final { + std::uint64_t frames_pushed{}; + std::uint64_t frames_popped{}; + std::uint64_t overflows{}; + std::uint64_t underruns{}; +}; + +template +class InjectorReplayRing final { +public: + static_assert(Capacity >= 2U); + + [[nodiscard]] constexpr std::size_t capacity() const noexcept { return Capacity; } + [[nodiscard]] std::size_t size() const noexcept { return size_; } + [[nodiscard]] std::size_t free_slots() const noexcept { return Capacity - size_; } + [[nodiscard]] bool empty() const noexcept { return size_ == 0U; } + [[nodiscard]] bool full() const noexcept { return size_ == Capacity; } + [[nodiscard]] const InjectorReplayStatistics& statistics() const noexcept { + return statistics_; + } + + void reset() noexcept { + read_index_ = 0U; + write_index_ = 0U; + size_ = 0U; + statistics_ = {}; + } + + [[nodiscard]] bool push(const ReplayPayloadFrame& frame) noexcept { + if (full()) { + ++statistics_.overflows; + return false; + } + frames_[write_index_] = frame; + write_index_ = next(write_index_); + ++size_; + ++statistics_.frames_pushed; + return true; + } + + [[nodiscard]] bool pop(ReplayPayloadFrame& frame) noexcept { + if (empty()) { + ++statistics_.underruns; + return false; + } + frame = frames_[read_index_]; + read_index_ = next(read_index_); + --size_; + ++statistics_.frames_popped; + return true; + } + +private: + [[nodiscard]] static constexpr std::size_t next( + const std::size_t index) noexcept { + return index + 1U == Capacity ? 0U : index + 1U; + } + + std::array frames_{}; + std::size_t read_index_{}; + std::size_t write_index_{}; + std::size_t size_{}; + InjectorReplayStatistics statistics_{}; +}; + +} // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/sampled_values/injector_runtime_program.hpp b/include/ariec61850/sampled_values/injector_runtime_program.hpp new file mode 100644 index 000000000..077101629 --- /dev/null +++ b/include/ariec61850/sampled_values/injector_runtime_program.hpp @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/deterministic_injector.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +inline constexpr std::size_t injector_runtime_program_capacity = 32U; + +enum class InjectorRuntimeSourceMode : std::uint8_t { + manual, + ramp, + sequence, +}; + +enum InjectorSignalField : std::uint16_t { + injector_field_enabled = 1U << 0U, + injector_field_rms = 1U << 1U, + injector_field_dc = 1U << 2U, + injector_field_phase = 1U << 3U, + injector_field_frequency = 1U << 4U, + injector_field_harmonic = 1U << 5U, + injector_field_harmonic_order = 1U << 6U, + injector_field_clip = 1U << 7U, + injector_field_quality = 1U << 8U, +}; + +struct InjectorChannelEdit final { + std::uint8_t channel_index{}; + std::uint16_t fields{}; + bool enabled{true}; + std::int32_t rms_counts{}; + std::int32_t dc_counts{}; + std::int32_t phase_millidegrees{}; + std::uint32_t frequency_millihz{50'000U}; + std::uint16_t harmonic_permyriad{}; + std::uint8_t harmonic_order{2U}; + std::uint16_t clip_permyriad{}; + std::uint32_t quality{}; + std::uint32_t duration_samples{}; +}; + +struct InjectorSequenceState final { + std::uint32_t duration_samples{}; + InjectorSegmentTransition transition{InjectorSegmentTransition::step}; + std::array channels{}; +}; + +// Fixed-capacity source program used for sample-boundary-safe runtime changes. +// +// The DeterministicSvInjector owns no copy of these segments; it reads this +// caller-owned ring. The publisher task is therefore the single writer and can +// stage a new manual profile, ramp, or finite sequence without resetting the +// logical sample index or smpCnt. The current hold slot is changed from an +// indefinite duration to one sample; the engine then advances naturally into +// the newly staged slots and applies its existing phase-offset transition. +class InjectorRuntimeProgram final { +public: + using ChannelProfiles = + std::array; + + explicit InjectorRuntimeProgram(const ChannelProfiles& initial) noexcept { + reset(initial); + } + + void reset(const ChannelProfiles& initial) noexcept { + for (auto& segment : segments_) { + segment.duration_samples = 0U; + segment.transition = InjectorSegmentTransition::step; + segment.channels = initial; + } + revision_ = 1U; + mode_ = InjectorRuntimeSourceMode::manual; + } + + [[nodiscard]] std::span segments() const noexcept { + return segments_; + } + + [[nodiscard]] std::uint32_t revision() const noexcept { return revision_; } + [[nodiscard]] InjectorRuntimeSourceMode mode() const noexcept { return mode_; } + + [[nodiscard]] bool is_hold_segment(const std::size_t segment_index) const noexcept { + return segment_index < segments_.size() && + segments_[segment_index].duration_samples == 0U; + } + + [[nodiscard]] const ChannelProfiles& profile_at( + const std::size_t segment_index) const noexcept { + return segments_[segment_index % segments_.size()].channels; + } + + [[nodiscard]] static bool apply_edit( + ChannelProfiles& profiles, + const InjectorChannelEdit& edit) noexcept { + if (edit.channel_index >= injector_channel_count || edit.fields == 0U) { + return false; + } + + auto& channel = profiles[edit.channel_index]; + if ((edit.fields & injector_field_enabled) != 0U) { + channel.enabled = edit.enabled; + } + if ((edit.fields & injector_field_rms) != 0U) { + channel.rms_counts = edit.rms_counts; + } + if ((edit.fields & injector_field_dc) != 0U) { + channel.dc_counts = edit.dc_counts; + } + if ((edit.fields & injector_field_phase) != 0U) { + channel.phase_millidegrees = edit.phase_millidegrees; + } + if ((edit.fields & injector_field_frequency) != 0U) { + channel.frequency_millihz = edit.frequency_millihz; + } + if ((edit.fields & injector_field_harmonic) != 0U) { + channel.harmonic_permyriad = edit.harmonic_permyriad; + } + if ((edit.fields & injector_field_harmonic_order) != 0U) { + channel.harmonic_order = edit.harmonic_order; + } + if ((edit.fields & injector_field_clip) != 0U) { + channel.clip_permyriad = edit.clip_permyriad; + } + if ((edit.fields & injector_field_quality) != 0U) { + channel.quality = edit.quality; + } + return true; + } + + [[nodiscard]] bool stage_manual( + const std::size_t current_segment, + const ChannelProfiles& target) noexcept { + if (!is_hold_segment(current_segment)) { + return false; + } + + const auto target_index = next_index(current_segment); + prepare_hold(target_index, target); + release_current(current_segment); + bump_revision(); + mode_ = InjectorRuntimeSourceMode::manual; + return true; + } + + [[nodiscard]] bool stage_manual_edit( + const std::size_t current_segment, + const InjectorChannelEdit& edit) noexcept { + if (!is_hold_segment(current_segment)) { + return false; + } + auto target = profile_at(current_segment); + if (!apply_edit(target, edit)) { + return false; + } + return stage_manual(current_segment, target); + } + + [[nodiscard]] bool stage_ramp( + const std::size_t current_segment, + const ChannelProfiles& target, + const std::uint32_t duration_samples) noexcept { + if (!is_hold_segment(current_segment) || duration_samples < 2U) { + return false; + } + + const auto ramp_index = next_index(current_segment); + const auto hold_index = next_index(ramp_index); + + segments_[ramp_index].duration_samples = duration_samples; + segments_[ramp_index].transition = InjectorSegmentTransition::linear_from_previous; + segments_[ramp_index].channels = target; + prepare_hold(hold_index, target); + release_current(current_segment); + bump_revision(); + mode_ = InjectorRuntimeSourceMode::ramp; + return true; + } + + [[nodiscard]] bool stage_ramp_edit( + const std::size_t current_segment, + const InjectorChannelEdit& edit) noexcept { + if (!is_hold_segment(current_segment) || edit.duration_samples < 2U) { + return false; + } + auto target = profile_at(current_segment); + if (!apply_edit(target, edit)) { + return false; + } + return stage_ramp(current_segment, target, edit.duration_samples); + } + + [[nodiscard]] bool stage_sequence( + const std::size_t current_segment, + const std::span states) noexcept { + if (!is_hold_segment(current_segment) || states.empty() || + states.size() + 1U >= segments_.size()) { + return false; + } + + auto index = next_index(current_segment); + for (const auto& state : states) { + if (state.duration_samples == 0U) { + return false; + } + segments_[index].duration_samples = state.duration_samples; + segments_[index].transition = state.transition; + segments_[index].channels = state.channels; + index = next_index(index); + } + + // A finite sequence always ends in an indefinite hold of its final + // state. Repeating/triggered programs are intentionally a separate + // orchestration concern so a malformed edit can never create an + // accidental endless test. + prepare_hold(index, states.back().channels); + release_current(current_segment); + bump_revision(); + mode_ = InjectorRuntimeSourceMode::sequence; + return true; + } + +private: + [[nodiscard]] std::size_t next_index(const std::size_t index) const noexcept { + return index + 1U == segments_.size() ? 0U : index + 1U; + } + + void release_current(const std::size_t current_segment) noexcept { + // duration=0 may have accumulated an arbitrarily large + // sample_in_segment in the injector. Setting it to one guarantees the + // very next completed sample advances to the newly staged slot. + segments_[current_segment].duration_samples = 1U; + } + + void prepare_hold( + const std::size_t index, + const ChannelProfiles& channels) noexcept { + segments_[index].duration_samples = 0U; + segments_[index].transition = InjectorSegmentTransition::step; + segments_[index].channels = channels; + } + + void bump_revision() noexcept { + ++revision_; + if (revision_ == 0U) { + ++revision_; + } + } + + std::array segments_{}; + std::uint32_t revision_{}; + InjectorRuntimeSourceMode mode_{InjectorRuntimeSourceMode::manual}; +}; + +} // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/sampled_values/injector_sequence_builder.hpp b/include/ariec61850/sampled_values/injector_sequence_builder.hpp new file mode 100644 index 000000000..ecebae5d9 --- /dev/null +++ b/include/ariec61850/sampled_values/injector_sequence_builder.hpp @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/injector_runtime_program.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +inline constexpr std::size_t injector_sequence_builder_capacity = 16U; + +class InjectorSequenceBuilder final { +public: + using ChannelProfiles = InjectorRuntimeProgram::ChannelProfiles; + + // Compatibility entry point for existing callers. New transactional + // control paths should pass the runtime source revision explicitly and + // reject a stale commit with based_on_revision(). + void begin(const ChannelProfiles& base) noexcept { + begin(base, 1U); + } + + void begin( + const ChannelProfiles& base, + const std::uint32_t source_revision) noexcept { + base_ = base; + working_ = base; + source_revision_ = source_revision; + state_count_ = 0U; + active_ = source_revision != 0U; + state_open_ = false; + pending_duration_samples_ = 0U; + pending_transition_ = InjectorSegmentTransition::step; + } + + void abort() noexcept { + active_ = false; + state_open_ = false; + state_count_ = 0U; + source_revision_ = 0U; + pending_duration_samples_ = 0U; + } + + [[nodiscard]] bool active() const noexcept { return active_; } + [[nodiscard]] bool state_open() const noexcept { return state_open_; } + [[nodiscard]] std::size_t state_count() const noexcept { return state_count_; } + [[nodiscard]] std::uint32_t source_revision() const noexcept { + return source_revision_; + } + [[nodiscard]] bool based_on_revision( + const std::uint32_t source_revision) const noexcept { + return active_ && source_revision_ == source_revision; + } + + [[nodiscard]] bool begin_state( + const std::uint32_t duration_samples, + const InjectorSegmentTransition transition) noexcept { + if (!active_ || state_open_ || duration_samples == 0U || + state_count_ >= states_.size()) { + return false; + } + + working_ = state_count_ == 0U + ? base_ + : states_[state_count_ - 1U].channels; + pending_duration_samples_ = duration_samples; + pending_transition_ = transition; + state_open_ = true; + return true; + } + + [[nodiscard]] bool edit_channel(const InjectorChannelEdit& edit) noexcept { + return state_open_ && InjectorRuntimeProgram::apply_edit(working_, edit); + } + + [[nodiscard]] bool commit_state() noexcept { + if (!active_ || !state_open_ || state_count_ >= states_.size()) { + return false; + } + states_[state_count_] = { + pending_duration_samples_, + pending_transition_, + working_}; + ++state_count_; + state_open_ = false; + pending_duration_samples_ = 0U; + return true; + } + + [[nodiscard]] bool ready() const noexcept { + return active_ && !state_open_ && state_count_ > 0U; + } + + [[nodiscard]] std::span states() const noexcept { + return std::span{states_.data(), state_count_}; + } + +private: + ChannelProfiles base_{}; + ChannelProfiles working_{}; + std::array states_{}; + std::size_t state_count_{}; + std::uint32_t source_revision_{}; + std::uint32_t pending_duration_samples_{}; + InjectorSegmentTransition pending_transition_{InjectorSegmentTransition::step}; + bool active_{}; + bool state_open_{}; +}; + +} // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/sampled_values/payload_writer.hpp b/include/ariec61850/sampled_values/payload_writer.hpp new file mode 100644 index 000000000..418149b56 --- /dev/null +++ b/include/ariec61850/sampled_values/payload_writer.hpp @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +// Helpers for the common SV seqOfData shape where an INT32 sample is followed +// by its 32-bit IEC 61850 quality word. The helper only writes caller-owned +// bytes; stream/DataSet semantics and engineering scaling remain application +// configuration responsibilities. +class SampledValuesPayloadWriter final { +public: + static constexpr std::size_t int32_quality_pair_bytes = 8U; + + [[nodiscard]] static constexpr std::size_t required_bytes_for_pairs( + const std::size_t pair_count) noexcept { + return pair_count > (static_cast(-1) / int32_quality_pair_bytes) + ? 0U + : pair_count * int32_quality_pair_bytes; + } + + [[nodiscard]] static bool write_int32_quality_pair( + const std::span payload, + const std::size_t pair_index, + const std::int32_t value, + const std::uint32_t quality) noexcept { + if (pair_index > (static_cast(-1) / int32_quality_pair_bytes)) { + return false; + } + const auto offset = pair_index * int32_quality_pair_bytes; + if (offset > payload.size() || + payload.size() - offset < int32_quality_pair_bytes) { + return false; + } + + write_u32_be(payload.subspan(offset, 4U), static_cast(value)); + write_u32_be(payload.subspan(offset + 4U, 4U), quality); + return true; + } + +private: + static void write_u32_be( + const std::span destination, + const std::uint32_t value) noexcept { + destination[0] = static_cast((value >> 24U) & 0xFFU); + destination[1] = static_cast((value >> 16U) & 0xFFU); + destination[2] = static_cast((value >> 8U) & 0xFFU); + destination[3] = static_cast(value & 0xFFU); + } +}; + +} // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/sampled_values/pdu_codec.hpp b/include/ariec61850/sampled_values/pdu_codec.hpp index 9fd31c8ce..15bde4be5 100644 --- a/include/ariec61850/sampled_values/pdu_codec.hpp +++ b/include/ariec61850/sampled_values/pdu_codec.hpp @@ -9,7 +9,9 @@ #include #include #include +#if !defined(ARIEC61850_NO_EXCEPTIONS) #include +#endif namespace ar::iec61850::sampled_values { @@ -22,12 +24,16 @@ class SampledValuesPduCodec final { const SampledValuesPdu& pdu, std::span destination) noexcept; - // Host convenience wrapper. Embedded steady-state publishers should use - // encode_into with caller-owned storage to avoid per-frame allocation. +#if !defined(ARIEC61850_NO_EXCEPTIONS) + // Host/soft-profile convenience wrapper. Hard embedded publishers use + // encode_into with caller-owned storage. [[nodiscard]] static std::vector encode(const SampledValuesPdu& pdu); + // The current dynamic decoder is retained outside the hard no-exception + // profile until its BER traversal/storage path is fully status-based. [[nodiscard]] static bool try_decode( std::span apdu, SampledValuesPdu& pdu) noexcept; +#endif }; } // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/sampled_values/publisher.hpp b/include/ariec61850/sampled_values/publisher.hpp new file mode 100644 index 000000000..91a6e8afa --- /dev/null +++ b/include/ariec61850/sampled_values/publisher.hpp @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/embedded/io.hpp" +#include "ariec61850/sampled_values/frame_codec.hpp" +#include "ariec61850/sampled_values/sample_counter.hpp" +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +// Deterministic, allocation-bounded publisher runtime for host and MCU use. +// +// The caller owns the SampledValuesFrame configuration and Ethernet buffer. +// Configuration-time std::string/std::vector storage inside SampledValuesFrame is +// intentionally retained for compatibility, but poll()/publish_now() do not grow +// or replace those containers. The steady-state path mutates only smpCnt, encodes +// into caller-owned storage, and hands the resulting bytes to RawEthernetPort. +// +// This class deliberately does not sleep or create a thread. A host event loop, +// FreeRTOS task, esp_timer callback, or bare-metal scheduler owns wake-up policy. +struct SampledValuesPublisherConfig final { + std::uint32_t sample_rate_hz{4'000U}; + std::optional sample_count_wrap{std::uint16_t{4'000U}}; + std::uint16_t initial_sample_count{}; + bool send_immediately{true}; +}; + +enum class SampledValuesPublishStatus : std::uint8_t { + sent, + not_due, + invalid_configuration, + encode_failed, + transmit_failed, +}; + +struct SampledValuesPublisherStatistics final { + std::uint64_t polls{}; + std::uint64_t frames_sent{}; + std::uint64_t encode_failures{}; + std::uint64_t transmit_failures{}; + std::uint64_t late_polls{}; + std::uint64_t maximum_lateness_us{}; +}; + +struct SampledValuesPublishResult final { + SampledValuesPublishStatus status{SampledValuesPublishStatus::not_due}; + wire::EncodeStatus encode_status{wire::EncodeStatus::ok}; + embedded::IoStatus io_status{embedded::IoStatus::ok}; + std::size_t frame_bytes{}; + std::uint16_t sample_count{}; + std::uint64_t next_due_us{}; + std::uint64_t lateness_us{}; + + [[nodiscard]] constexpr bool sent() const noexcept { + return status == SampledValuesPublishStatus::sent; + } +}; + +class SampledValuesPublisher final { +public: + static constexpr std::uint32_t maximum_supported_sample_rate_hz = 1'000'000U; + + SampledValuesPublisher( + SampledValuesFrame& frame, + const std::span frame_buffer, + const embedded::RawEthernetPort ethernet_port, + const SampledValuesPublisherConfig config = {}) noexcept + : frame_(&frame), + frame_buffer_(frame_buffer), + ethernet_port_(ethernet_port), + config_(config), + sample_count_(config.initial_sample_count) { + valid_ = + config_.sample_rate_hz > 0U && + config_.sample_rate_hz <= maximum_supported_sample_rate_hz && + !frame_buffer_.empty() && + ethernet_port_.transmit != nullptr && + !frame_->pdu.asdus.empty(); + apply_sample_count(); + } + + [[nodiscard]] bool valid() const noexcept { return valid_; } + [[nodiscard]] bool started() const noexcept { return started_; } + [[nodiscard]] std::uint16_t next_sample_count() const noexcept { return sample_count_; } + [[nodiscard]] std::uint64_t next_due_us() const noexcept { return next_due_us_; } + [[nodiscard]] const SampledValuesPublisherStatistics& statistics() const noexcept { + return statistics_; + } + + // Reset pacing and optionally the counter. The first later poll() starts a new + // publisher session. This is the intended way to apply a deliberate rate or + // stream-session change rather than feeding scheduler lateness back into pacing. + void reset(const std::uint16_t sample_count = 0U) noexcept { + started_ = false; + next_due_us_ = 0U; + interval_remainder_ = 0U; + sample_count_ = sample_count; + apply_sample_count(); + } + + // Poll the deterministic pacer at a monotonic timestamp. At most one frame is + // attempted per call. After scheduler lateness, the next deadline is anchored + // to the actual attempt time, preventing catch-up bursts. + [[nodiscard]] SampledValuesPublishResult poll(const std::uint64_t now_us) noexcept { + ++statistics_.polls; + + if (!valid_) { + return make_result( + SampledValuesPublishStatus::invalid_configuration, + wire::EncodeStatus::value_out_of_range, + embedded::IoStatus::invalid_argument, + 0U, + sample_count_, + 0U); + } + + if (!started_) { + started_ = true; + next_due_us_ = config_.send_immediately + ? now_us + : saturating_add(now_us, next_interval_us()); + } + + if (now_us < next_due_us_) { + return make_result( + SampledValuesPublishStatus::not_due, + wire::EncodeStatus::ok, + embedded::IoStatus::ok, + 0U, + sample_count_, + 0U); + } + + const auto lateness = now_us - next_due_us_; + if (lateness > 0U) { + ++statistics_.late_polls; + statistics_.maximum_lateness_us = + std::max(statistics_.maximum_lateness_us, lateness); + } + + return transmit_current(now_us, lateness); + } + + // Bypass due-time checking but keep the same no-catch-up scheduling rule. + // Useful when an external high-resolution timer already provides the 4 kHz tick. + [[nodiscard]] SampledValuesPublishResult publish_now( + const std::uint64_t now_us) noexcept { + ++statistics_.polls; + + if (!valid_) { + return make_result( + SampledValuesPublishStatus::invalid_configuration, + wire::EncodeStatus::value_out_of_range, + embedded::IoStatus::invalid_argument, + 0U, + sample_count_, + 0U); + } + + if (!started_) { + started_ = true; + } + return transmit_current(now_us, 0U); + } + +private: + [[nodiscard]] static std::uint64_t saturating_add( + const std::uint64_t left, + const std::uint64_t right) noexcept { + if (right > std::numeric_limits::max() - left) { + return std::numeric_limits::max(); + } + return left + right; + } + + [[nodiscard]] std::uint64_t next_interval_us() noexcept { + const auto rate = static_cast(config_.sample_rate_hz); + auto interval = 1'000'000ULL / rate; + interval_remainder_ += 1'000'000ULL % rate; + if (interval_remainder_ >= rate) { + ++interval; + interval_remainder_ -= rate; + } + return interval; + } + + void schedule_after(const std::uint64_t now_us) noexcept { + next_due_us_ = saturating_add(now_us, next_interval_us()); + } + + void apply_sample_count() noexcept { + if (frame_ == nullptr) { + return; + } + for (auto& asdu : frame_->pdu.asdus) { + asdu.sample_count = sample_count_; + } + } + + [[nodiscard]] SampledValuesPublishResult make_result( + const SampledValuesPublishStatus status, + const wire::EncodeStatus encode_status, + const embedded::IoStatus io_status, + const std::size_t frame_bytes, + const std::uint16_t sample_count, + const std::uint64_t lateness_us) const noexcept { + return { + status, + encode_status, + io_status, + frame_bytes, + sample_count, + next_due_us_, + lateness_us}; + } + + [[nodiscard]] SampledValuesPublishResult transmit_current( + const std::uint64_t now_us, + const std::uint64_t lateness_us) noexcept { + const auto transmitted_sample_count = sample_count_; + apply_sample_count(); + + const auto encoded = SampledValuesFrameCodec::encode_into( + *frame_, frame_buffer_); + if (!encoded.success()) { + ++statistics_.encode_failures; + schedule_after(now_us); + return make_result( + SampledValuesPublishStatus::encode_failed, + encoded.status, + embedded::IoStatus::ok, + 0U, + transmitted_sample_count, + lateness_us); + } + + const auto bytes = frame_buffer_.first(encoded.bytes_written); + const auto transmitted = ethernet_port_.send(bytes); + if (!transmitted.success() || transmitted.transferred != encoded.bytes_written) { + ++statistics_.transmit_failures; + schedule_after(now_us); + const auto status = transmitted.success() + ? embedded::IoStatus::io_error + : transmitted.status; + return make_result( + SampledValuesPublishStatus::transmit_failed, + wire::EncodeStatus::ok, + status, + encoded.bytes_written, + transmitted_sample_count, + lateness_us); + } + + ++statistics_.frames_sent; + sample_count_ = SampleCounterPolicy::increment( + sample_count_, config_.sample_count_wrap); + apply_sample_count(); + schedule_after(now_us); + return make_result( + SampledValuesPublishStatus::sent, + wire::EncodeStatus::ok, + embedded::IoStatus::ok, + encoded.bytes_written, + transmitted_sample_count, + lateness_us); + } + + SampledValuesFrame* frame_{}; + std::span frame_buffer_{}; + embedded::RawEthernetPort ethernet_port_{}; + SampledValuesPublisherConfig config_{}; + SampledValuesPublisherStatistics statistics_{}; + std::uint64_t next_due_us_{}; + std::uint64_t interval_remainder_{}; + std::uint16_t sample_count_{}; + bool valid_{}; + bool started_{}; +}; + +} // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/sampled_values/replay_bundle.hpp b/include/ariec61850/sampled_values/replay_bundle.hpp new file mode 100644 index 000000000..40ef01e93 --- /dev/null +++ b/include/ariec61850/sampled_values/replay_bundle.hpp @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/deterministic_injector.hpp" +#include "ariec61850/sampled_values/payload_writer.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +inline constexpr std::size_t replay_payload_bytes = + injector_channel_count * SampledValuesPayloadWriter::int32_quality_pair_bytes; +inline constexpr std::size_t replay_bundle_header_bytes = 32U; +inline constexpr std::array replay_bundle_magic{ + 'A', 'R', 'S', 'V', 'R', 'P', 'L', '1'}; + +struct ReplayBundleHeader final { + std::uint32_t version{1U}; + std::uint32_t sample_rate_hz{4'000U}; + std::uint32_t payload_bytes{static_cast(replay_payload_bytes)}; + std::uint32_t channel_count{static_cast(injector_channel_count)}; + std::uint64_t frame_count{}; +}; + +using ReplayPayloadFrame = std::array; + +namespace detail { + +inline void write_be32( + const std::span bytes, + const std::size_t offset, + const std::uint32_t value) noexcept { + bytes[offset] = static_cast((value >> 24U) & 0xFFU); + bytes[offset + 1U] = static_cast((value >> 16U) & 0xFFU); + bytes[offset + 2U] = static_cast((value >> 8U) & 0xFFU); + bytes[offset + 3U] = static_cast(value & 0xFFU); +} + +inline void write_be64( + const std::span bytes, + const std::size_t offset, + const std::uint64_t value) noexcept { + write_be32(bytes, offset, static_cast(value >> 32U)); + write_be32(bytes, offset + 4U, static_cast(value & 0xFFFF'FFFFULL)); +} + +[[nodiscard]] inline std::uint32_t read_be32( + const std::span bytes, + const std::size_t offset) noexcept { + return (static_cast(bytes[offset]) << 24U) | + (static_cast(bytes[offset + 1U]) << 16U) | + (static_cast(bytes[offset + 2U]) << 8U) | + static_cast(bytes[offset + 3U]); +} + +[[nodiscard]] inline std::uint64_t read_be64( + const std::span bytes, + const std::size_t offset) noexcept { + return (static_cast(read_be32(bytes, offset)) << 32U) | + static_cast(read_be32(bytes, offset + 4U)); +} + +} // namespace detail + +[[nodiscard]] inline bool encode_replay_bundle_header( + const ReplayBundleHeader& header, + const std::span output) noexcept { + if (output.size() < replay_bundle_header_bytes || + header.version != 1U || header.sample_rate_hz == 0U || + header.payload_bytes != replay_payload_bytes || + header.channel_count != injector_channel_count) { + return false; + } + + for (std::size_t index = 0U; index < replay_bundle_magic.size(); ++index) { + output[index] = replay_bundle_magic[index]; + } + detail::write_be32(output, 8U, header.version); + detail::write_be32(output, 12U, header.sample_rate_hz); + detail::write_be32(output, 16U, header.payload_bytes); + detail::write_be32(output, 20U, header.channel_count); + detail::write_be64(output, 24U, header.frame_count); + return true; +} + +[[nodiscard]] inline bool decode_replay_bundle_header( + const std::span input, + ReplayBundleHeader& header) noexcept { + if (input.size() < replay_bundle_header_bytes) { + return false; + } + for (std::size_t index = 0U; index < replay_bundle_magic.size(); ++index) { + if (input[index] != replay_bundle_magic[index]) { + return false; + } + } + + header.version = detail::read_be32(input, 8U); + header.sample_rate_hz = detail::read_be32(input, 12U); + header.payload_bytes = detail::read_be32(input, 16U); + header.channel_count = detail::read_be32(input, 20U); + header.frame_count = detail::read_be64(input, 24U); + + return header.version == 1U && header.sample_rate_hz > 0U && + header.payload_bytes == replay_payload_bytes && + header.channel_count == injector_channel_count; +} + +[[nodiscard]] inline std::uint64_t replay_bundle_expected_bytes( + const ReplayBundleHeader& header) noexcept { + if (header.payload_bytes != replay_payload_bytes) { + return 0U; + } + constexpr auto maximum = std::numeric_limits::max(); + const auto payload = static_cast(header.payload_bytes); + if (header.frame_count > + (maximum - static_cast(replay_bundle_header_bytes)) / payload) { + return 0U; + } + return static_cast(replay_bundle_header_bytes) + + header.frame_count * payload; +} + +} // namespace ar::iec61850::sampled_values diff --git a/ports/esp_idf/CMakeLists.txt b/ports/esp_idf/CMakeLists.txt new file mode 100644 index 000000000..9243edebe --- /dev/null +++ b/ports/esp_idf/CMakeLists.txt @@ -0,0 +1,51 @@ +# arstack61850 ESP-IDF component entry point. +# +# Add this directory to EXTRA_COMPONENT_DIRS from an ESP-IDF application. +# The MCU component remains deliberately narrower than the desktop/host core: it +# contains the bounded dependencies required to build/transmit Sampled Values, +# the ESP-IDF raw-Ethernet adapter, and the raw-partition adapter used by the +# storage-agnostic BRCB checkpoint journal. Host discovery, dynamic BER decode, +# generic Ethernet object codecs, MMS-over-TCP runtime, filesystem and CLI +# sources stay outside this target-specific link boundary. + +set(ARIEC61850_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") + +set(ARIEC61850_ESP_IDF_SOURCES + ${ARIEC61850_ROOT}/src/mms/utc_time.cpp + + ${ARIEC61850_ROOT}/src/sampled_values/pdu_codec.cpp + ${ARIEC61850_ROOT}/src/sampled_values/frame_codec.cpp + ${ARIEC61850_ROOT}/src/sampled_values/sample_counter.cpp + + ${CMAKE_CURRENT_LIST_DIR}/ethernet_port.cpp + ${CMAKE_CURRENT_LIST_DIR}/brcb_flash_storage.cpp) + +idf_component_register( + SRCS ${ARIEC61850_ESP_IDF_SOURCES} + INCLUDE_DIRS + ${ARIEC61850_ROOT}/include + ${CMAKE_CURRENT_LIST_DIR}/include + REQUIRES esp_eth esp_timer esp_partition) + +target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_20) +target_compile_definitions(${COMPONENT_LIB} PUBLIC + ARIEC61850_EMBEDDED_PROFILE=1 + ARIEC61850_HOST_SERVICES=0 + ARIEC61850_NO_EXCEPTIONS=1) + +# The physical transmit/storage adapters remain part of the hard embedded +# profile: no C++ exceptions and no RTTI. Host convenience/decode APIs are +# excluded at compile time and all protocol/storage paths use status returns and +# caller-owned buffers. +target_compile_options(${COMPONENT_LIB} PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion + -fno-exceptions -fno-rtti) + +# ESP-IDF vendor C headers intentionally use GNU extensions such as #include_next +# and anonymous structs. Suppress only pedantic diagnostics for the platform +# adapter translation units; ESP-IDF's global -Werror and every other arstack +# warning remain active. +set_source_files_properties( + ${CMAKE_CURRENT_LIST_DIR}/ethernet_port.cpp + ${CMAKE_CURRENT_LIST_DIR}/brcb_flash_storage.cpp + PROPERTIES COMPILE_OPTIONS "-Wno-pedantic") diff --git a/ports/esp_idf/brcb_flash_probe_app/CMakeLists.txt b/ports/esp_idf/brcb_flash_probe_app/CMakeLists.txt new file mode 100644 index 000000000..aae71fba3 --- /dev/null +++ b/ports/esp_idf/brcb_flash_probe_app/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.16) + +# Destructive-on-probe-sector flash geometry/latency measurement image. +# The BRCB journal banks themselves are never touched by the probe routine. +set(EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/..") + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(arstack61850_esp32p4_brcb_flash_probe) diff --git a/ports/esp_idf/brcb_flash_probe_app/README.md b/ports/esp_idf/brcb_flash_probe_app/README.md new file mode 100644 index 000000000..f4e975cf1 --- /dev/null +++ b/ports/esp_idf/brcb_flash_probe_app/README.md @@ -0,0 +1,78 @@ +# ESP32-P4 BRCB flash geometry / latency probe + +This image validates the physical non-volatile backend used by the bounded BRCB +A/B checkpoint journal. It is intentionally separate from the normal SV trial +firmware because the latency probe performs destructive flash operations. + +## Partition policy + +The application-defined `brcb_state` partition uses type `0x40`, subtype `0x01`. +The adapter reads the actual `esp_partition_t::erase_size` at boot and requires at +least three erase units: + +1. erase unit 0: journal bank A; +2. erase unit 1: journal bank B; +3. erase unit 2: destructive measurement probe. + +Only the first two units are exposed through `MmsStaticBrcbStorageBackend`. +`run_latency_probe()` cannot address either journal bank. + +Each checkpoint attempt that reaches the storage erase stage erases exactly one +journal erase unit. A/B alternation means one bank sees at most +`ceil(checkpoint_erase_attempts / 2)` erase cycles. No flash endurance rating is +hard-coded; use the actual flash/device rating when converting this counter into +an endurance budget. + +The current CSV reserves `0x3000` bytes. On a target reporting a `0x1000` erase +size this maps exactly to three erase units. If the runtime geometry differs, the +adapter fails closed instead of silently changing the journal layout. + +Encrypted and read-only partitions are rejected by this first backend. Encrypted +partition write alignment and power-loss behavior require a separate acceptance +slice before they can be enabled. + +## Measurement transaction + +One probe run performs, only in the dedicated probe unit: + +1. full erase; +2. 40-byte header write; +3. 3072-byte synthetic state-payload write; +4. 32-byte footer write at the fixed bank footer position; +5. full read/verify of header, payload and footer; +6. cleanup erase. + +A successful run therefore consumes two erase cycles on the **probe** unit and +zero erase cycles on the journal banks. + +The UART output uses machine-readable records: + +```text +ARBRCB_FLASH {"stage":"geometry",...} +ARBRCB_FLASH {"stage":"latency-probe",...} +``` + +The second record reports prepare-erase, header/payload/footer write, verify-read +and cleanup-erase latency in microseconds. Timing is measured with +`esp_timer_get_time()`. + +## Build and physical run + +From an ESP-IDF v6.0.2 environment: + +```text +idf.py set-target esp32p4 +idf.py build +idf.py -p flash monitor +``` + +Run the probe only when a destructive measurement of its reserved scratch sector +is intended. Do not use repeated probe executions as an endurance test unless a +separate wear-test plan and erase-cycle budget have been approved. + +## Evidence boundary + +Hosted CI can prove that the adapter, custom partition table and probe firmware +cross-compile for ESP32-P4. It cannot provide physical flash latency, real +power-cut survival or endurance data. Those labels are reserved for captured +board output and controlled hardware tests. diff --git a/ports/esp_idf/brcb_flash_probe_app/main/CMakeLists.txt b/ports/esp_idf/brcb_flash_probe_app/main/CMakeLists.txt new file mode 100644 index 000000000..6fea1cef5 --- /dev/null +++ b/ports/esp_idf/brcb_flash_probe_app/main/CMakeLists.txt @@ -0,0 +1,4 @@ +idf_component_register( + SRCS "main.cpp" + INCLUDE_DIRS "." + REQUIRES esp_idf) diff --git a/ports/esp_idf/brcb_flash_probe_app/main/main.cpp b/ports/esp_idf/brcb_flash_probe_app/main/main.cpp new file mode 100644 index 000000000..a447d39b4 --- /dev/null +++ b/ports/esp_idf/brcb_flash_probe_app/main/main.cpp @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/ports/esp_idf/brcb_flash_storage.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +using ar::iec61850::ports::esp_idf::BrcbFlashStorage; + +std::array g_probe_payload{}; + +} // namespace + +extern "C" void app_main(void) { + using namespace ar::iec61850::ports::esp_idf; + + BrcbFlashStorage storage; + const auto status = storage.initialize(); + const auto& geometry = storage.geometry(); + + std::printf( + "ARBRCB_FLASH {\"stage\":\"geometry\",\"status\":\"%s\"," + "\"partitionBytes\":%u,\"eraseBytes\":%u,\"bankBytes\":%u," + "\"journalBytes\":%u,\"probeOffset\":%u,\"probeBytes\":%u," + "\"maximumStateBytes\":%u,\"eraseUnitsPerCheckpoint\":%u," + "\"unusedBytes\":%u,\"encrypted\":%s,\"readOnly\":%s}\n", + brcb_flash_status_name(status), + static_cast(geometry.partition_bytes), + static_cast(geometry.erase_bytes), + static_cast(geometry.bank_bytes), + static_cast(geometry.journal_bytes), + static_cast(geometry.probe_offset), + static_cast(geometry.probe_bytes), + static_cast(geometry.maximum_state_bytes), + static_cast(geometry.erase_units_per_checkpoint), + static_cast(geometry.unused_bytes), + geometry.encrypted ? "true" : "false", + geometry.read_only ? "true" : "false"); + std::fflush(stdout); + + if (!storage.ready()) { + return; + } + + for (std::size_t index = 0U; index < g_probe_payload.size(); ++index) { + g_probe_payload[index] = static_cast( + (index * 37U + 0x5AU) & 0xFFU); + } + + const auto result = storage.run_latency_probe( + std::span{g_probe_payload}); + std::printf( + "ARBRCB_FLASH {\"stage\":\"latency-probe\",\"status\":\"%s\"," + "\"payloadBytes\":%u,\"prepareEraseUs\":%" PRIu64 "," + "\"headerWriteUs\":%" PRIu64 ",\"payloadWriteUs\":%" PRIu64 "," + "\"footerWriteUs\":%" PRIu64 ",\"totalWriteUs\":%" PRIu64 "," + "\"verifyReadUs\":%" PRIu64 ",\"cleanupEraseUs\":%" PRIu64 "," + "\"probeEraseUnits\":%" PRIu64 ",\"cleanupSucceeded\":%s}\n", + brcb_flash_probe_status_name(result.status), + static_cast(result.payload_bytes), + result.prepare_erase_us, + result.header_write_us, + result.payload_write_us, + result.footer_write_us, + result.total_write_us(), + result.verify_read_us, + result.cleanup_erase_us, + result.probe_erase_units, + result.cleanup_succeeded ? "true" : "false"); + std::fflush(stdout); +} diff --git a/ports/esp_idf/brcb_flash_probe_app/partitions.csv b/ports/esp_idf/brcb_flash_probe_app/partitions.csv new file mode 100644 index 000000000..87077131c --- /dev/null +++ b/ports/esp_idf/brcb_flash_probe_app/partitions.csv @@ -0,0 +1,5 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x6000, +phy_init, data, phy, 0xf000, 0x1000, +factory, app, factory, 0x10000, 1M, +brcb_state, 0x40, 0x01, , 0x3000, diff --git a/ports/esp_idf/brcb_flash_probe_app/sdkconfig.defaults b/ports/esp_idf/brcb_flash_probe_app/sdkconfig.defaults new file mode 100644 index 000000000..cb07216fc --- /dev/null +++ b/ports/esp_idf/brcb_flash_probe_app/sdkconfig.defaults @@ -0,0 +1,5 @@ +CONFIG_COMPILER_CXX_EXCEPTIONS=n +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +CONFIG_ESP_CONSOLE_UART_DEFAULT=y +CONFIG_ESP_CONSOLE_UART_BAUDRATE=115200 diff --git a/ports/esp_idf/brcb_flash_storage.cpp b/ports/esp_idf/brcb_flash_storage.cpp new file mode 100644 index 000000000..5797cd4ba --- /dev/null +++ b/ports/esp_idf/brcb_flash_storage.cpp @@ -0,0 +1,431 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/ports/esp_idf/brcb_flash_storage.hpp" + +#include "esp_partition.h" +#include "esp_timer.h" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::ports::esp_idf { +namespace { + +[[nodiscard]] std::uint64_t timer_us() noexcept { + const auto value = esp_timer_get_time(); + return value <= 0 ? 0U : static_cast(value); +} + +[[nodiscard]] std::uint64_t elapsed_us(const std::uint64_t start) noexcept { + const auto end = timer_us(); + return end >= start ? end - start : 0U; +} + +[[nodiscard]] bool range_valid( + const std::size_t offset, + const std::size_t bytes, + const std::size_t capacity) noexcept { + return offset <= capacity && bytes <= capacity - offset; +} + +void record_latency( + std::uint64_t& total, + std::uint64_t& maximum, + const std::uint64_t elapsed) noexcept { + total += elapsed; + maximum = std::max(maximum, elapsed); +} + +} // namespace + +BrcbFlashStatus BrcbFlashStorage::initialize(const char* label) noexcept { + partition_ = nullptr; + geometry_ = {}; + metrics_ = {}; + status_ = BrcbFlashStatus::partition_not_found; + + if (label == nullptr) { + return status_; + } + + partition_ = esp_partition_find_first( + static_cast(partition_type), + static_cast(partition_subtype), + label); + if (partition_ == nullptr) { + return status_; + } + + geometry_.partition_bytes = static_cast(partition_->size); + geometry_.erase_bytes = static_cast(partition_->erase_size); + geometry_.encrypted = partition_->encrypted; + geometry_.read_only = partition_->readonly; + + if (partition_->readonly) { + status_ = BrcbFlashStatus::read_only_partition; + return status_; + } + if (partition_->encrypted) { + // The current journal emits byte-sized writes including a 40-byte header. + // Keep this first physical backend fail-closed until encrypted-partition + // alignment and power-fail behavior are characterized separately. + status_ = BrcbFlashStatus::encrypted_partition_not_supported; + return status_; + } + + const auto erase_bytes = geometry_.erase_bytes; + if (erase_bytes == 0U || + (static_cast(partition_->address) % erase_bytes) != 0U || + (geometry_.partition_bytes % erase_bytes) != 0U) { + status_ = BrcbFlashStatus::invalid_geometry; + return status_; + } + + constexpr std::size_t required_units = journal_bank_count + probe_sector_count; + if (erase_bytes > (std::numeric_limits::max() / required_units)) { + status_ = BrcbFlashStatus::invalid_geometry; + return status_; + } + const auto required_bytes = erase_bytes * required_units; + if (geometry_.partition_bytes < required_bytes) { + status_ = BrcbFlashStatus::partition_too_small; + return status_; + } + if (erase_bytes <= + mms::MmsStaticBrcbCheckpointStore::record_header_bytes + + mms::MmsStaticBrcbCheckpointStore::record_footer_bytes) { + status_ = BrcbFlashStatus::invalid_geometry; + return status_; + } + + geometry_.bank_bytes = erase_bytes; + geometry_.journal_bytes = erase_bytes * journal_bank_count; + geometry_.probe_offset = geometry_.journal_bytes; + geometry_.probe_bytes = erase_bytes; + geometry_.unused_bytes = geometry_.partition_bytes - required_bytes; + geometry_.maximum_state_bytes = + erase_bytes - mms::MmsStaticBrcbCheckpointStore::record_header_bytes - + mms::MmsStaticBrcbCheckpointStore::record_footer_bytes; + geometry_.erase_units_per_checkpoint = 1U; + + status_ = geometry_.valid() + ? BrcbFlashStatus::ok + : BrcbFlashStatus::invalid_geometry; + return status_; +} + +mms::MmsStaticBrcbStorageBackend BrcbFlashStorage::backend() noexcept { + if (!ready()) { + return {}; + } + return mms::MmsStaticBrcbStorageBackend{ + this, + geometry_.journal_bytes, + &BrcbFlashStorage::storage_read, + &BrcbFlashStorage::storage_write, + &BrcbFlashStorage::storage_erase, + &BrcbFlashStorage::storage_sync}; +} + +bool BrcbFlashStorage::storage_read( + void* context, + const std::size_t offset, + const std::span destination) noexcept { + auto* storage = static_cast(context); + if (storage == nullptr || !storage->ready() || + !range_valid(offset, destination.size(), storage->geometry_.journal_bytes)) { + return false; + } + ++storage->metrics_.read_calls; + if (destination.empty()) { + return true; + } + + const auto start = timer_us(); + const auto status = esp_partition_read( + storage->partition_, offset, destination.data(), destination.size()); + const auto elapsed = elapsed_us(start); + record_latency( + storage->metrics_.read_us_total, + storage->metrics_.read_us_max, + elapsed); + if (status != ESP_OK) { + ++storage->metrics_.read_failures; + return false; + } + storage->metrics_.read_bytes += static_cast(destination.size()); + return true; +} + +bool BrcbFlashStorage::storage_write( + void* context, + const std::size_t offset, + const std::span source) noexcept { + auto* storage = static_cast(context); + if (storage == nullptr || !storage->ready() || + !range_valid(offset, source.size(), storage->geometry_.journal_bytes)) { + return false; + } + ++storage->metrics_.write_calls; + if (source.empty()) { + return true; + } + + const auto start = timer_us(); + const auto status = esp_partition_write( + storage->partition_, offset, source.data(), source.size()); + const auto elapsed = elapsed_us(start); + record_latency( + storage->metrics_.write_us_total, + storage->metrics_.write_us_max, + elapsed); + if (status != ESP_OK) { + ++storage->metrics_.write_failures; + return false; + } + storage->metrics_.write_bytes += static_cast(source.size()); + return true; +} + +bool BrcbFlashStorage::storage_erase( + void* context, + const std::size_t offset, + const std::size_t bytes) noexcept { + auto* storage = static_cast(context); + if (storage == nullptr || !storage->ready() || bytes == 0U || + !range_valid(offset, bytes, storage->geometry_.journal_bytes) || + (offset % storage->geometry_.erase_bytes) != 0U || + (bytes % storage->geometry_.erase_bytes) != 0U) { + return false; + } + ++storage->metrics_.erase_calls; + + const auto start = timer_us(); + const auto status = esp_partition_erase_range(storage->partition_, offset, bytes); + const auto elapsed = elapsed_us(start); + record_latency( + storage->metrics_.erase_us_total, + storage->metrics_.erase_us_max, + elapsed); + if (status != ESP_OK) { + ++storage->metrics_.erase_failures; + return false; + } + storage->metrics_.erased_bytes += static_cast(bytes); + return true; +} + +bool BrcbFlashStorage::storage_sync(void* context) noexcept { + auto* storage = static_cast(context); + if (storage == nullptr || !storage->ready()) { + return false; + } + // esp_partition read/write/erase calls are blocking operations. There is no + // additional asynchronous flash queue owned by this adapter, so completion + // of the preceding API call is the durable ordering boundary available here. + ++storage->metrics_.sync_calls; + return true; +} + +BrcbFlashProbeResult BrcbFlashStorage::run_latency_probe( + const std::span payload) noexcept { + BrcbFlashProbeResult result; + result.payload_bytes = payload.size(); + if (!ready()) { + result.status = BrcbFlashProbeStatus::not_ready; + return result; + } + if (payload.empty() || payload.size() > geometry_.maximum_state_bytes) { + result.status = BrcbFlashProbeStatus::payload_too_large; + return result; + } + + std::array + header{}; + std::array + footer{}; + for (std::size_t index = 0U; index < header.size(); ++index) { + header[index] = static_cast(0x40U + (index & 0x3FU)); + } + for (std::size_t index = 0U; index < footer.size(); ++index) { + footer[index] = static_cast(0xA0U + (index & 0x1FU)); + } + + const auto cleanup = [&]() noexcept { + const auto start = timer_us(); + const auto erased = esp_partition_erase_range( + partition_, geometry_.probe_offset, geometry_.probe_bytes); + result.cleanup_erase_us = elapsed_us(start); + result.cleanup_succeeded = erased == ESP_OK; + if (result.cleanup_succeeded) { + ++result.probe_erase_units; + } + return result.cleanup_succeeded; + }; + + auto start = timer_us(); + auto status = esp_partition_erase_range( + partition_, geometry_.probe_offset, geometry_.probe_bytes); + result.prepare_erase_us = elapsed_us(start); + if (status != ESP_OK) { + result.status = BrcbFlashProbeStatus::prepare_erase_failed; + return result; + } + ++result.probe_erase_units; + + start = timer_us(); + status = esp_partition_write( + partition_, geometry_.probe_offset, header.data(), header.size()); + result.header_write_us = elapsed_us(start); + if (status != ESP_OK) { + result.status = BrcbFlashProbeStatus::header_write_failed; + (void)cleanup(); + return result; + } + + start = timer_us(); + status = esp_partition_write( + partition_, + geometry_.probe_offset + header.size(), + payload.data(), + payload.size()); + result.payload_write_us = elapsed_us(start); + if (status != ESP_OK) { + result.status = BrcbFlashProbeStatus::payload_write_failed; + (void)cleanup(); + return result; + } + + const auto footer_offset = + geometry_.probe_offset + geometry_.probe_bytes - footer.size(); + start = timer_us(); + status = esp_partition_write( + partition_, footer_offset, footer.data(), footer.size()); + result.footer_write_us = elapsed_us(start); + if (status != ESP_OK) { + result.status = BrcbFlashProbeStatus::footer_write_failed; + (void)cleanup(); + return result; + } + + std::array + header_read{}; + std::array + footer_read{}; + std::array chunk{}; + + const auto read_start = timer_us(); + status = esp_partition_read( + partition_, geometry_.probe_offset, header_read.data(), header_read.size()); + if (status != ESP_OK) { + result.verify_read_us = elapsed_us(read_start); + result.status = BrcbFlashProbeStatus::header_read_failed; + (void)cleanup(); + return result; + } + + bool payload_matches = true; + std::size_t consumed = 0U; + while (consumed < payload.size()) { + const auto bytes = std::min(chunk.size(), payload.size() - consumed); + status = esp_partition_read( + partition_, + geometry_.probe_offset + header.size() + consumed, + chunk.data(), + bytes); + if (status != ESP_OK) { + result.verify_read_us = elapsed_us(read_start); + result.status = BrcbFlashProbeStatus::payload_read_failed; + (void)cleanup(); + return result; + } + if (!std::equal( + chunk.begin(), + chunk.begin() + static_cast(bytes), + payload.subspan(consumed, bytes).begin())) { + payload_matches = false; + } + consumed += bytes; + } + + status = esp_partition_read( + partition_, footer_offset, footer_read.data(), footer_read.size()); + result.verify_read_us = elapsed_us(read_start); + if (status != ESP_OK) { + result.status = BrcbFlashProbeStatus::footer_read_failed; + (void)cleanup(); + return result; + } + + const auto verified = + std::equal(header.begin(), header.end(), header_read.begin()) && + payload_matches && + std::equal(footer.begin(), footer.end(), footer_read.begin()); + if (!verified) { + result.status = BrcbFlashProbeStatus::verify_failed; + (void)cleanup(); + return result; + } + + if (!cleanup()) { + result.status = BrcbFlashProbeStatus::cleanup_erase_failed; + return result; + } + + result.status = BrcbFlashProbeStatus::ok; + return result; +} + +const char* brcb_flash_status_name(const BrcbFlashStatus status) noexcept { + switch (status) { + case BrcbFlashStatus::ok: + return "ok"; + case BrcbFlashStatus::partition_not_found: + return "partition-not-found"; + case BrcbFlashStatus::read_only_partition: + return "read-only-partition"; + case BrcbFlashStatus::encrypted_partition_not_supported: + return "encrypted-partition-not-supported"; + case BrcbFlashStatus::invalid_geometry: + return "invalid-geometry"; + case BrcbFlashStatus::partition_too_small: + return "partition-too-small"; + } + return "unknown"; +} + +const char* brcb_flash_probe_status_name(const BrcbFlashProbeStatus status) noexcept { + switch (status) { + case BrcbFlashProbeStatus::ok: + return "ok"; + case BrcbFlashProbeStatus::not_ready: + return "not-ready"; + case BrcbFlashProbeStatus::payload_too_large: + return "payload-too-large"; + case BrcbFlashProbeStatus::prepare_erase_failed: + return "prepare-erase-failed"; + case BrcbFlashProbeStatus::header_write_failed: + return "header-write-failed"; + case BrcbFlashProbeStatus::payload_write_failed: + return "payload-write-failed"; + case BrcbFlashProbeStatus::footer_write_failed: + return "footer-write-failed"; + case BrcbFlashProbeStatus::header_read_failed: + return "header-read-failed"; + case BrcbFlashProbeStatus::payload_read_failed: + return "payload-read-failed"; + case BrcbFlashProbeStatus::footer_read_failed: + return "footer-read-failed"; + case BrcbFlashProbeStatus::verify_failed: + return "verify-failed"; + case BrcbFlashProbeStatus::cleanup_erase_failed: + return "cleanup-erase-failed"; + } + return "unknown"; +} + +} // namespace ar::iec61850::ports::esp_idf diff --git a/ports/esp_idf/ethernet_port.cpp b/ports/esp_idf/ethernet_port.cpp new file mode 100644 index 000000000..e066be1da --- /dev/null +++ b/ports/esp_idf/ethernet_port.cpp @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/ports/esp_idf/ethernet_port.hpp" + +#include "esp_err.h" +#include "esp_timer.h" + +#include + +namespace ar::iec61850::ports::esp_idf { + +embedded::IoResult raw_ethernet_transmit( + void* context, + const std::span frame) noexcept { + auto* adapter = static_cast(context); + if (adapter == nullptr || adapter->handle == nullptr || frame.empty()) { + return {embedded::IoStatus::invalid_argument, 0U}; + } + + // ESP-IDF's API currently accepts a mutable void* even though transmit does + // not require arstack to modify the frame. Keep the const_cast contained in + // this platform boundary instead of leaking it into the protocol core. + const auto result = esp_eth_transmit( + adapter->handle, + const_cast(frame.data()), + frame.size()); + + if (result == ESP_OK) { + return {embedded::IoStatus::ok, frame.size()}; + } + if (result == ESP_ERR_TIMEOUT) { + return {embedded::IoStatus::timeout, 0U}; + } + if (result == ESP_ERR_INVALID_ARG) { + return {embedded::IoStatus::invalid_argument, 0U}; + } + if (result == ESP_ERR_INVALID_STATE) { + return {embedded::IoStatus::closed, 0U}; + } + return {embedded::IoStatus::io_error, 0U}; +} + +embedded::RawEthernetPort make_raw_ethernet_port( + RawEthernetContext& context) noexcept { + return {&context, &raw_ethernet_transmit}; +} + +std::uint64_t monotonic_microseconds(void*) noexcept { + const auto value = esp_timer_get_time(); + return value <= 0 ? 0U : static_cast(value); +} + +embedded::Clock make_monotonic_clock() noexcept { + return {nullptr, &monotonic_microseconds, nullptr}; +} + +} // namespace ar::iec61850::ports::esp_idf diff --git a/ports/esp_idf/include/ariec61850/ports/esp_idf/brcb_flash_storage.hpp b/ports/esp_idf/include/ariec61850/ports/esp_idf/brcb_flash_storage.hpp new file mode 100644 index 000000000..b34e0ac76 --- /dev/null +++ b/ports/esp_idf/include/ariec61850/ports/esp_idf/brcb_flash_storage.hpp @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/static_brcb_checkpoint_store.hpp" + +#include "esp_partition.h" + +#include +#include +#include +#include + +namespace ar::iec61850::ports::esp_idf { + +enum class BrcbFlashStatus : std::uint8_t { + ok, + partition_not_found, + read_only_partition, + encrypted_partition_not_supported, + invalid_geometry, + partition_too_small, +}; + +struct BrcbFlashGeometry final { + std::size_t partition_bytes{}; + std::size_t erase_bytes{}; + std::size_t bank_bytes{}; + std::size_t journal_bytes{}; + std::size_t probe_offset{}; + std::size_t probe_bytes{}; + std::size_t unused_bytes{}; + std::size_t maximum_state_bytes{}; + std::size_t erase_units_per_checkpoint{}; + bool encrypted{}; + bool read_only{}; + + [[nodiscard]] constexpr bool valid() const noexcept { + return erase_bytes != 0U && bank_bytes == erase_bytes && + journal_bytes == bank_bytes * 2U && probe_bytes == erase_bytes && + probe_offset == journal_bytes && maximum_state_bytes != 0U && + erase_units_per_checkpoint == 1U; + } +}; + +struct BrcbFlashMetrics final { + std::uint64_t read_calls{}; + std::uint64_t write_calls{}; + std::uint64_t erase_calls{}; + std::uint64_t sync_calls{}; + std::uint64_t read_failures{}; + std::uint64_t write_failures{}; + std::uint64_t erase_failures{}; + std::uint64_t read_bytes{}; + std::uint64_t write_bytes{}; + std::uint64_t erased_bytes{}; + std::uint64_t read_us_total{}; + std::uint64_t write_us_total{}; + std::uint64_t erase_us_total{}; + std::uint64_t read_us_max{}; + std::uint64_t write_us_max{}; + std::uint64_t erase_us_max{}; + + [[nodiscard]] constexpr std::uint64_t logical_erase_units( + const std::size_t erase_bytes) const noexcept { + return erase_bytes == 0U + ? 0U + : erased_bytes / static_cast(erase_bytes); + } +}; + +enum class BrcbFlashProbeStatus : std::uint8_t { + ok, + not_ready, + payload_too_large, + prepare_erase_failed, + header_write_failed, + payload_write_failed, + footer_write_failed, + header_read_failed, + payload_read_failed, + footer_read_failed, + verify_failed, + cleanup_erase_failed, +}; + +struct BrcbFlashProbeResult final { + BrcbFlashProbeStatus status{BrcbFlashProbeStatus::not_ready}; + std::size_t payload_bytes{}; + std::uint64_t prepare_erase_us{}; + std::uint64_t header_write_us{}; + std::uint64_t payload_write_us{}; + std::uint64_t footer_write_us{}; + std::uint64_t verify_read_us{}; + std::uint64_t cleanup_erase_us{}; + std::uint64_t probe_erase_units{}; + bool cleanup_succeeded{}; + + [[nodiscard]] constexpr bool success() const noexcept { + return status == BrcbFlashProbeStatus::ok; + } + + [[nodiscard]] constexpr std::uint64_t total_write_us() const noexcept { + return header_write_us + payload_write_us + footer_write_us; + } +}; + +// ESP-IDF raw-partition adapter for the bounded BRCB A/B checkpoint journal. +// +// Layout policy: +// erase unit 0 = journal bank A +// erase unit 1 = journal bank B +// erase unit 2 = destructive latency-probe scratch area +// +// Exactly one erase unit is therefore erased per checkpoint attempt that reaches +// the storage erase stage. The probe sector is deliberately outside the backend +// capacity exposed to MmsStaticBrcbCheckpointStore so a latency measurement can +// never overwrite either committed journal bank. +class BrcbFlashStorage final { +public: + static constexpr std::uint8_t partition_type = 0x40U; + static constexpr std::uint8_t partition_subtype = 0x01U; + static constexpr std::size_t journal_bank_count = 2U; + static constexpr std::size_t probe_sector_count = 1U; + static constexpr const char* default_partition_label = "brcb_state"; + + [[nodiscard]] BrcbFlashStatus initialize( + const char* label = default_partition_label) noexcept; + + [[nodiscard]] constexpr bool ready() const noexcept { + return status_ == BrcbFlashStatus::ok && partition_ != nullptr && geometry_.valid(); + } + + [[nodiscard]] constexpr BrcbFlashStatus status() const noexcept { + return status_; + } + + [[nodiscard]] constexpr const BrcbFlashGeometry& geometry() const noexcept { + return geometry_; + } + + [[nodiscard]] constexpr const BrcbFlashMetrics& metrics() const noexcept { + return metrics_; + } + + void reset_metrics() noexcept { + metrics_ = {}; + } + + [[nodiscard]] mms::MmsStaticBrcbStorageBackend backend() noexcept; + + [[nodiscard]] mms::MmsStaticBrcbCheckpointStore checkpoint_store() noexcept { + return mms::MmsStaticBrcbCheckpointStore{backend(), geometry_.bank_bytes}; + } + + // Destructive only inside the dedicated probe erase unit. The payload is + // written using the same header/payload/footer write shape as the journal, + // then read back and verified before the probe unit is erased again. + [[nodiscard]] BrcbFlashProbeResult run_latency_probe( + std::span payload) noexcept; + + // A/B alternation means each journal bank is erased at most every other + // checkpoint erase attempt. These helpers intentionally avoid assuming any + // vendor-specific flash endurance rating; feed the actual flash rating into + // checkpoint_attempts_for_bank_cycles() when hardware data is available. + [[nodiscard]] static constexpr std::uint64_t maximum_bank_erase_cycles( + const std::uint64_t checkpoint_erase_attempts) noexcept { + return (checkpoint_erase_attempts + 1U) / 2U; + } + + [[nodiscard]] static constexpr std::uint64_t checkpoint_attempts_for_bank_cycles( + const std::uint64_t rated_bank_erase_cycles) noexcept { + return rated_bank_erase_cycles > + (std::numeric_limits::max() / 2U) + ? std::numeric_limits::max() + : rated_bank_erase_cycles * 2U; + } + +private: + [[nodiscard]] static bool storage_read( + void* context, + std::size_t offset, + std::span destination) noexcept; + [[nodiscard]] static bool storage_write( + void* context, + std::size_t offset, + std::span source) noexcept; + [[nodiscard]] static bool storage_erase( + void* context, + std::size_t offset, + std::size_t bytes) noexcept; + [[nodiscard]] static bool storage_sync(void* context) noexcept; + + const esp_partition_t* partition_{}; + BrcbFlashStatus status_{BrcbFlashStatus::partition_not_found}; + BrcbFlashGeometry geometry_{}; + BrcbFlashMetrics metrics_{}; +}; + +[[nodiscard]] const char* brcb_flash_status_name(BrcbFlashStatus status) noexcept; +[[nodiscard]] const char* brcb_flash_probe_status_name(BrcbFlashProbeStatus status) noexcept; + +} // namespace ar::iec61850::ports::esp_idf diff --git a/ports/esp_idf/include/ariec61850/ports/esp_idf/ethernet_port.hpp b/ports/esp_idf/include/ariec61850/ports/esp_idf/ethernet_port.hpp new file mode 100644 index 000000000..11eca2cf0 --- /dev/null +++ b/ports/esp_idf/include/ariec61850/ports/esp_idf/ethernet_port.hpp @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/embedded/io.hpp" + +#include "esp_eth.h" + +namespace ar::iec61850::ports::esp_idf { + +// Small adapter context owned by the ESP-IDF application. The protocol core +// never sees esp_eth_handle_t; only this platform adapter does. +struct RawEthernetContext final { + esp_eth_handle_t handle{}; +}; + +[[nodiscard]] embedded::IoResult raw_ethernet_transmit( + void* context, + std::span frame) noexcept; + +[[nodiscard]] embedded::RawEthernetPort make_raw_ethernet_port( + RawEthernetContext& context) noexcept; + +[[nodiscard]] std::uint64_t monotonic_microseconds(void* context) noexcept; + +[[nodiscard]] embedded::Clock make_monotonic_clock() noexcept; + +} // namespace ar::iec61850::ports::esp_idf diff --git a/ports/esp_idf/smoke_app/CMakeLists.txt b/ports/esp_idf/smoke_app/CMakeLists.txt new file mode 100644 index 000000000..f151d6382 --- /dev/null +++ b/ports/esp_idf/smoke_app/CMakeLists.txt @@ -0,0 +1,6 @@ +cmake_minimum_required(VERSION 3.16) + +set(EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/..") + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(arstack61850_esp32p4_compile_smoke) diff --git a/ports/esp_idf/smoke_app/main/CMakeLists.txt b/ports/esp_idf/smoke_app/main/CMakeLists.txt new file mode 100644 index 000000000..6fea1cef5 --- /dev/null +++ b/ports/esp_idf/smoke_app/main/CMakeLists.txt @@ -0,0 +1,4 @@ +idf_component_register( + SRCS "main.cpp" + INCLUDE_DIRS "." + REQUIRES esp_idf) diff --git a/ports/esp_idf/smoke_app/main/main.cpp b/ports/esp_idf/smoke_app/main/main.cpp new file mode 100644 index 000000000..b4938db88 --- /dev/null +++ b/ports/esp_idf/smoke_app/main/main.cpp @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/embedded/profile.hpp" +#include "ariec61850/ports/esp_idf/brcb_flash_storage.hpp" +#include "ariec61850/ports/esp_idf/ethernet_port.hpp" +#include "ariec61850/sampled_values/payload_writer.hpp" +#include "ariec61850/sampled_values/publisher.hpp" + +#include +#include +#include + +extern "C" void app_main(void) { + using namespace ar::iec61850; + + static_assert(embedded::Esp32SmallProfile::ethernet_frame_bytes >= 1'522U); + static_assert(sampled_values::SampledValuesPublisher::maximum_supported_sample_rate_hz >= 4'000U); + static_assert(sampled_values::SampledValuesPayloadWriter::int32_quality_pair_bytes == 8U); + static_assert(ports::esp_idf::BrcbFlashStorage::maximum_bank_erase_cycles(3U) == 2U); + static_assert(ports::esp_idf::BrcbFlashStorage::checkpoint_attempts_for_bank_cycles(10U) == 20U); + + ports::esp_idf::RawEthernetContext context{}; + const auto port = ports::esp_idf::make_raw_ethernet_port(context); + const auto clock = ports::esp_idf::make_monotonic_clock(); + + ports::esp_idf::BrcbFlashStorage flash_storage; + const auto flash_status = flash_storage.initialize(); + const auto flash_backend = flash_storage.backend(); + const auto flash_store = flash_storage.checkpoint_store(); + + std::array payload{}; + const auto wrote = sampled_values::SampledValuesPayloadWriter::write_int32_quality_pair( + std::span{payload}, 0U, 1234, 0U); + + // Build-only smoke: no Ethernet/flash hardware is exercised on hosted CI. + // Keep the objects live so adapter symbols, partition APIs and C++20 span + // usage are compiled and linked for the real ESP32-P4 toolchain. + (void)port; + (void)clock; + (void)flash_status; + (void)flash_backend; + (void)flash_store; + (void)wrote; +} diff --git a/ports/esp_idf/smoke_app/partitions.csv b/ports/esp_idf/smoke_app/partitions.csv new file mode 100644 index 000000000..87077131c --- /dev/null +++ b/ports/esp_idf/smoke_app/partitions.csv @@ -0,0 +1,5 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x6000, +phy_init, data, phy, 0xf000, 0x1000, +factory, app, factory, 0x10000, 1M, +brcb_state, 0x40, 0x01, , 0x3000, diff --git a/ports/esp_idf/smoke_app/sdkconfig.defaults b/ports/esp_idf/smoke_app/sdkconfig.defaults new file mode 100644 index 000000000..d66b198eb --- /dev/null +++ b/ports/esp_idf/smoke_app/sdkconfig.defaults @@ -0,0 +1,6 @@ +# The MCU component is compiled as a hard no-exception/no-RTTI transmit/storage slice. +CONFIG_COMPILER_CXX_EXCEPTIONS=n + +# Reserve two raw BRCB journal banks plus one destructive latency-probe erase unit. +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" diff --git a/ports/esp_idf/sv_trial_app/CMakeLists.txt b/ports/esp_idf/sv_trial_app/CMakeLists.txt new file mode 100644 index 000000000..f859ac46a --- /dev/null +++ b/ports/esp_idf/sv_trial_app/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.16) + +# Flashable first-trial firmware for the Waveshare ESP32-P4-ETH board. +# The protocol core remains a separate ESP-IDF component one directory above. +set(EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/..") + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(arstack61850_esp32p4_sv_trial) diff --git a/ports/esp_idf/sv_trial_app/README.md b/ports/esp_idf/sv_trial_app/README.md new file mode 100644 index 000000000..d73b9de85 --- /dev/null +++ b/ports/esp_idf/sv_trial_app/README.md @@ -0,0 +1,158 @@ +# ESP32-P4 Sampled Values injector firmware + +This is the first flashable arstack61850 Sampled Values injector target for ESP32-P4. The firmware uses the same fixed-point `DeterministicSvInjector` waveform engine and canonical 4I+4V profile as the Windows reference injector. + +## Hardware profile + +- board target: Waveshare ESP32-P4-ETH +- ESP-IDF target: `esp32p4` +- Ethernet: internal ESP32-P4 EMAC + RMII +- PHY: IP101-compatible IEEE 802.3 path +- PHY address: 1 +- PHY reset GPIO: 51 +- management/control: UART0 through the onboard Type-C USB-to-UART bridge +- UART0 TX: GPIO37 +- UART0 RX: GPIO38 +- control baud rate: 115200, 8 data bits, no parity, one stop bit +- Sampled Values: 50 Hz / 80 samples per cycle / 4,000 sample instants per second +- APPID: `0x4001` +- destination MAC: `01:0C:CD:04:00:01` +- `svID`: `ARSTACK61850_INJECTOR` +- dataset: `ARSTACK61850/LLN0$PhsMeas1` +- untagged Ethernet first proof +- one ASDU per Ethernet frame +- channel order: `Ia, Ib, Ic, In, Va, Vb, Vc, Vn` +- 8 x `INT32 + quality` pairs (64-byte `seqOfData`) + +The first profile uses raw engineering counts shared with the Windows reference path. It is not yet a claim of IEC 61869-9 calibrated instrument-transformer scaling. + +## Deterministic execution model + +The logical waveform advances by integer sample index through the shared fixed-point engine. The 250 us ESP timer and publisher task decide when a frame may be attempted; they do not decide which waveform sample logically comes next. + +If a timer notification arrives before the publisher is due, the firmware retains the same pending sample rather than advancing the waveform. If encode or physical TX fails, the first control slice enters `FAULT` instead of silently allowing waveform sample index and `smpCnt` to diverge. + +## UART0 control plane + +The board's Type-C programming/debug connector is implemented through an onboard USB-to-UART bridge connected to ESP32-P4 UART0. The injector therefore keeps UART0 as primary standard input/output so the same `COMx` port used for flashing and monitoring can carry injector commands after boot. + +The protocol is line-delimited JSON, schema: + +```text +arstack-sv-injector-control-v1 +``` + +Each request is one JSON object terminated by a newline. Responses are one-line JSON prefixed by `ARCTRL ` so a PC application can separate control responses from firmware log output. + +Supported lifecycle commands: + +```json +{"command":"capabilities"} +{"command":"status"} +{"command":"configure","scenario":"normal"} +{"command":"configure","scenario":"protection-fault"} +{"command":"arm"} +{"command":"start"} +{"command":"stop"} +{"command":"stats"} +``` + +Realtime source commands additionally provide sparse channel edits, sample-indexed ramps, and transactional finite state-sequence construction. + +Lifecycle: + +```text +IDLE -> CONFIGURED -> ARMED -> RUNNING -> STOPPED + | | + +-> STOPPED + | + FAULT +``` + +On boot the firmware validates and stores the `normal` profile, then waits in `CONFIGURED`. A normal run is therefore: + +```json +{"command":"status"} +{"command":"arm"} +{"command":"start"} +``` + +Stop with: + +```json +{"command":"stop"} +``` + +To change scenario after stopping: + +```json +{"command":"configure","scenario":"protection-fault"} +{"command":"arm"} +{"command":"start"} +``` + +A `configure` command is rejected while `ARMED` or `RUNNING`. `start` is rejected unless the active configuration has been armed. A fault can be recovered by sending a new valid `configure`, then `arm`, then `start`. + +## Build + +From an ESP-IDF v6.0.2 shell in this directory: + +```bash +idf.py set-target esp32p4 +idf.py build +``` + +GitHub Actions builds the same project with ESP-IDF v6.0.2. A successful workflow uploads the complete flashable build set as the `esp32p4-sv-trial-firmware` artifact, including the application image, bootloader, partition table, ELF, map, `flash_args`, `flasher_args.json`, generated sdkconfig, checksums, and build evidence. + +## Flash + +Use the board Type-C programming/debug COM port: + +```bash +idf.py -p flash +``` + +A downloaded CI artifact can also be flashed with compatible flashing tools using the included `flash_args`/`flasher_args.json`. Keep the extracted directory structure intact so relative binary paths remain valid. + +If a serial monitor is used after flashing, close it before starting a separate device-control client because the control client opens the COM port exclusively. + +## Expected runtime behavior + +After boot the firmware: + +1. initializes the ESP32-P4 EMAC and PHY; +2. initializes the deterministic 4I+4V control configuration; +3. starts the 250 us timer and the high-priority publisher task; +4. exposes the JSON control plane on primary UART0 stdio through the Type-C USB-to-UART bridge; +5. remains `CONFIGURED` until `arm` and `start` are received; +6. while `RUNNING`, emits at most one SV frame for each processed timer opportunity; +7. pauses transmission while Ethernet link is down without consuming logical waveform samples; +8. reports publisher/timer/heap telemetry and responds to `stats` on demand. + +The `stats` response includes frames sent, encode/TX failures, late polls, maximum lateness, timer notifications, and coalesced notifications. Periodic firmware QA logs additionally include heap watermarks. + +## First physical acceptance + +1. flash the firmware through the Type-C UART programming/debug port; +2. close any serial monitor that still owns the COM port; +3. connect Ethernet; +4. verify `status` reports `CONFIGURED` and Ethernet link state; +5. send `arm`, then `start`; +6. capture EtherType `0x88BA` with an independent receiver; +7. confirm APPID `0x4001`, `svID`, dataset, 4I+4V waveform, 50 Hz, and `smpCnt` continuity; +8. compare values/phase sequence against the Windows reference injector; +9. exercise realtime channel edits, ramps, and finite state sequences; +10. request `stats` and retain the telemetry as evidence; +11. send `stop` and verify the stream ceases. + +## What CI proves without hardware + +A green repository can prove: + +- portable protocol/control core builds with strict GCC and Clang warnings; +- deterministic waveform and injector lifecycle/parser smoke tests pass; +- host-sim publisher behavior passes CTest; +- ESP32-P4 RISC-V cross-compilation and final link succeed under ESP-IDF v6.0.2; +- a complete flashable firmware image set is produced. + +CI cannot prove USB-to-UART driver/cable behavior, physical UART command exchange, PHY electrical bring-up, real Ethernet transmission, DMA behavior under sustained 4 kHz traffic, scheduler jitter on silicon, or external subscriber interoperability. Those remain explicit hardware gates. diff --git a/ports/esp_idf/sv_trial_app/main/CMakeLists.txt b/ports/esp_idf/sv_trial_app/main/CMakeLists.txt new file mode 100644 index 000000000..ccbb6319a --- /dev/null +++ b/ports/esp_idf/sv_trial_app/main/CMakeLists.txt @@ -0,0 +1,4 @@ +idf_component_register( + SRCS "main.cpp" + INCLUDE_DIRS "." + REQUIRES esp_idf esp_eth esp_event esp_timer) diff --git a/ports/esp_idf/sv_trial_app/main/main.cpp b/ports/esp_idf/sv_trial_app/main/main.cpp new file mode 100644 index 000000000..b7cda76ed --- /dev/null +++ b/ports/esp_idf/sv_trial_app/main/main.cpp @@ -0,0 +1,852 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/ports/esp_idf/ethernet_port.hpp" +#include "ariec61850/sampled_values/deterministic_injector.hpp" +#include "ariec61850/sampled_values/frame.hpp" +#include "ariec61850/sampled_values/injector_control_protocol.hpp" +#include "ariec61850/sampled_values/injector_controller.hpp" +#include "ariec61850/sampled_values/injector_presets.hpp" +#include "ariec61850/sampled_values/injector_runtime_program.hpp" +#include "ariec61850/sampled_values/injector_sequence_builder.hpp" +#include "ariec61850/sampled_values/payload_writer.hpp" +#include "ariec61850/sampled_values/publisher.hpp" + +#include "esp_eth.h" +#include "esp_event.h" +#include "esp_log.h" +#include "esp_system.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" +#include "freertos/queue.h" +#include "freertos/task.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::uint32_t kSampleRateHz = 4'000U; +constexpr std::uint64_t kPublishIntervalUs = 250U; +constexpr std::uint16_t kSampleCountWrap = 4'000U; +constexpr std::size_t kPayloadBytes = + sampled_values::injector_channel_count * + sampled_values::SampledValuesPayloadWriter::int32_quality_pair_bytes; +constexpr std::size_t kControlLineBytes = 256U; +constexpr std::size_t kControlQueueDepth = 16U; + +// Waveshare ESP32-P4-ETH: IP101 PHY on RMII. ESP32-P4's +// ETH_ESP32_EMAC_DEFAULT_CONFIG() supplies the matching default RMII data-plane +// GPIOs; this board-specific layer supplies the PHY management values. +constexpr std::int32_t kPhyAddress = 1; +constexpr int kPhyResetGpio = 51; + +constexpr EventBits_t kEthernetLinkUpBit = BIT0; +constexpr UBaseType_t kPublisherTaskPriority = 18U; +constexpr UBaseType_t kControlTaskPriority = 8U; +constexpr std::uint32_t kPublisherTaskStackBytes = 9'216U; +constexpr std::uint32_t kControlTaskStackBytes = 4'096U; + +const char* kTag = "arstack_sv_trial"; +EventGroupHandle_t g_link_events{}; +TaskHandle_t g_publisher_task{}; +esp_eth_handle_t g_eth_handle{}; +QueueHandle_t g_control_queue{}; +sampled_values::InjectorController g_injector_controller{}; +// Keep fixed-capacity source/edit state out of the publisher task stack. Both +// objects are mutated only by the publisher task after commands cross the queue. +std::optional g_runtime_program{}; +sampled_values::InjectorSequenceBuilder g_sequence_builder{}; + +[[nodiscard]] const char* state_name( + const sampled_values::InjectorControlState state) noexcept { + switch (state) { + case sampled_values::InjectorControlState::idle: + return "IDLE"; + case sampled_values::InjectorControlState::configured: + return "CONFIGURED"; + case sampled_values::InjectorControlState::armed: + return "ARMED"; + case sampled_values::InjectorControlState::running: + return "RUNNING"; + case sampled_values::InjectorControlState::stopped: + return "STOPPED"; + case sampled_values::InjectorControlState::fault: + return "FAULT"; + } + return "UNKNOWN"; +} + +[[nodiscard]] const char* scenario_name( + const sampled_values::InjectorScenarioKind scenario) noexcept { + return scenario == sampled_values::InjectorScenarioKind::protection_fault + ? "protection-fault" + : "normal"; +} + +[[nodiscard]] const char* runtime_mode_name( + const sampled_values::InjectorRuntimeSourceMode mode) noexcept { + switch (mode) { + case sampled_values::InjectorRuntimeSourceMode::manual: + return "manual"; + case sampled_values::InjectorRuntimeSourceMode::ramp: + return "ramp"; + case sampled_values::InjectorRuntimeSourceMode::sequence: + return "sequence"; + } + return "unknown"; +} + +[[nodiscard]] const char* command_name( + const sampled_values::InjectorControlCommandKind command) noexcept { + switch (command) { + case sampled_values::InjectorControlCommandKind::capabilities: + return "capabilities"; + case sampled_values::InjectorControlCommandKind::configure: + return "configure"; + case sampled_values::InjectorControlCommandKind::arm: + return "arm"; + case sampled_values::InjectorControlCommandKind::start: + return "start"; + case sampled_values::InjectorControlCommandKind::stop: + return "stop"; + case sampled_values::InjectorControlCommandKind::status: + return "status"; + case sampled_values::InjectorControlCommandKind::stats: + return "stats"; + case sampled_values::InjectorControlCommandKind::set_channel: + return "set-channel"; + case sampled_values::InjectorControlCommandKind::ramp_channel: + return "ramp-channel"; + case sampled_values::InjectorControlCommandKind::sequence_begin: + return "sequence-begin"; + case sampled_values::InjectorControlCommandKind::sequence_state_begin: + return "sequence-state-begin"; + case sampled_values::InjectorControlCommandKind::sequence_set_channel: + return "sequence-set-channel"; + case sampled_values::InjectorControlCommandKind::sequence_state_commit: + return "sequence-state-commit"; + case sampled_values::InjectorControlCommandKind::sequence_commit: + return "sequence-commit"; + case sampled_values::InjectorControlCommandKind::sequence_abort: + return "sequence-abort"; + } + return "unknown"; +} + +void print_control_error( + const char* command, + const char* error) noexcept { + std::printf( + "ARCTRL {\"schemaVersion\":\"arstack-sv-injector-control-v1\"," + "\"command\":\"%s\",\"ok\":false,\"error\":\"%s\"}\n", + command, + error); + std::fflush(stdout); +} + +void print_control_ack( + const sampled_values::InjectorControlCommandKind command) noexcept { + const auto snapshot = g_injector_controller.snapshot(); + std::printf( + "ARCTRL {\"schemaVersion\":\"arstack-sv-injector-control-v1\"," + "\"command\":\"%s\",\"ok\":true,\"state\":\"%s\"," + "\"scenario\":\"%s\",\"configurationRevision\":%u," + "\"armedRevision\":%u,\"runSequence\":%" PRIu64, + command_name(command), + state_name(snapshot.state), + scenario_name(snapshot.configuration.scenario), + static_cast(snapshot.configuration_revision), + static_cast(snapshot.armed_revision), + snapshot.run_sequence); + if (g_runtime_program.has_value()) { + std::printf( + ",\"sourceMode\":\"%s\",\"sourceRevision\":%u", + runtime_mode_name(g_runtime_program->mode()), + static_cast(g_runtime_program->revision())); + } + if (g_sequence_builder.active()) { + std::printf( + ",\"sequenceDraftStates\":%u,\"sequenceStateOpen\":%s", + static_cast(g_sequence_builder.state_count()), + g_sequence_builder.state_open() ? "true" : "false"); + } + std::printf("}\n"); + std::fflush(stdout); +} + +void print_capabilities() noexcept { + std::printf( + "ARCTRL {\"schemaVersion\":\"arstack-sv-injector-control-v1\"," + "\"command\":\"capabilities\",\"ok\":true," + "\"transport\":\"usb-serial-jtag-stdio\"," + "\"engine\":\"sample-index-fixed-point\"," + "\"commands\":[\"capabilities\",\"configure\",\"arm\",\"start\"," + "\"stop\",\"status\",\"stats\",\"set-channel\",\"ramp-channel\"," + "\"sequence-begin\",\"sequence-state-begin\",\"sequence-set-channel\"," + "\"sequence-state-commit\",\"sequence-commit\",\"sequence-abort\"]," + "\"sourceModes\":[\"manual\",\"ramp\",\"sequence\"]," + "\"scenarios\":[\"normal\",\"protection-fault\"]," + "\"profile\":{\"sampleRateHz\":4000,\"sampleCountWrap\":4000," + "\"channels\":8,\"payload\":\"INT32+quality\",\"appid\":16385}}\n"); + std::fflush(stdout); +} + +void print_status() noexcept { + const auto snapshot = g_injector_controller.snapshot(); + const auto link_up = + (xEventGroupGetBits(g_link_events) & kEthernetLinkUpBit) != 0U; + std::printf( + "ARCTRL {\"schemaVersion\":\"arstack-sv-injector-control-v1\"," + "\"command\":\"status\",\"ok\":true,\"state\":\"%s\"," + "\"scenario\":\"%s\",\"configurationRevision\":%u," + "\"armedRevision\":%u,\"runSequence\":%" PRIu64 "," + "\"ethernetLinkUp\":%s", + state_name(snapshot.state), + scenario_name(snapshot.configuration.scenario), + static_cast(snapshot.configuration_revision), + static_cast(snapshot.armed_revision), + snapshot.run_sequence, + link_up ? "true" : "false"); + if (g_runtime_program.has_value()) { + std::printf( + ",\"sourceMode\":\"%s\",\"sourceRevision\":%u", + runtime_mode_name(g_runtime_program->mode()), + static_cast(g_runtime_program->revision())); + } + if (g_sequence_builder.active()) { + std::printf( + ",\"sequenceDraftStates\":%u,\"sequenceStateOpen\":%s", + static_cast(g_sequence_builder.state_count()), + g_sequence_builder.state_open() ? "true" : "false"); + } + std::printf("}\n"); + std::fflush(stdout); +} + +void ethernet_event_handler( + void* argument, + esp_event_base_t event_base, + const std::int32_t event_id, + void* event_data) { + (void)argument; + (void)event_base; + + auto handle = *static_cast(event_data); + switch (event_id) { + case ETHERNET_EVENT_CONNECTED: { + std::array mac{}; + if (esp_eth_ioctl(handle, ETH_CMD_G_MAC_ADDR, mac.data()) == ESP_OK) { + ESP_LOGI( + kTag, + "Ethernet link up, MAC %02x:%02x:%02x:%02x:%02x:%02x", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + } else { + ESP_LOGI(kTag, "Ethernet link up"); + } + xEventGroupSetBits(g_link_events, kEthernetLinkUpBit); + break; + } + case ETHERNET_EVENT_DISCONNECTED: + xEventGroupClearBits(g_link_events, kEthernetLinkUpBit); + ESP_LOGW(kTag, "Ethernet link down; SV transmission paused"); + break; + case ETHERNET_EVENT_START: + ESP_LOGI(kTag, "Ethernet driver started"); + break; + case ETHERNET_EVENT_STOP: + xEventGroupClearBits(g_link_events, kEthernetLinkUpBit); + ESP_LOGI(kTag, "Ethernet driver stopped"); + break; + default: + break; + } +} + +esp_err_t initialize_ethernet(esp_eth_handle_t* out_handle) { + if (out_handle == nullptr) { + return ESP_ERR_INVALID_ARG; + } + + eth_mac_config_t mac_config = ETH_MAC_DEFAULT_CONFIG(); + eth_esp32_emac_config_t emac_config = ETH_ESP32_EMAC_DEFAULT_CONFIG(); + eth_phy_config_t phy_config = ETH_PHY_DEFAULT_CONFIG(); + phy_config.phy_addr = kPhyAddress; + phy_config.reset_gpio_num = kPhyResetGpio; + + esp_eth_mac_t* mac = esp_eth_mac_new_esp32(&emac_config, &mac_config); + if (mac == nullptr) { + return ESP_FAIL; + } + + esp_eth_phy_t* phy = esp_eth_phy_new_generic(&phy_config); + if (phy == nullptr) { + mac->del(mac); + return ESP_FAIL; + } + + esp_eth_config_t driver_config = ETH_DEFAULT_CONFIG(mac, phy); + const auto installed = esp_eth_driver_install(&driver_config, out_handle); + if (installed != ESP_OK) { + mac->del(mac); + phy->del(phy); + return installed; + } + + return ESP_OK; +} + +sampled_values::SampledValuesFrame make_sv_frame( + const std::array& source_mac) { + const std::array destination_mac{ + 0x01U, 0x0CU, 0xCDU, 0x04U, 0x00U, 0x01U}; + + sampled_values::SampledValueAsdu asdu; + asdu.sv_id = "ARSTACK61850_INJECTOR"; + asdu.data_set_reference = "ARSTACK61850/LLN0$PhsMeas1"; + asdu.configuration_revision = 1U; + asdu.sample_synchronization = 0U; + asdu.sample_rate = std::uint16_t{kSampleRateHz}; + asdu.sample_mode = std::uint16_t{1U}; + asdu.sample_payload.resize(kPayloadBytes, 0U); + + return { + ethernet::MacAddress{destination_mac}, + ethernet::MacAddress{source_mac}, + std::nullopt, + 0x4001U, + 0U, + 0U, + sampled_values::SampledValuesPdu{{asdu}}}; +} + +[[nodiscard]] std::array +make_protection_fault_scenario() noexcept { + const auto normal = sampled_values::make_balanced_4i4v_profile(); + auto fault = normal; + for (std::size_t channel = 0U; channel < 3U; ++channel) { + fault[channel].rms_counts *= 4; + } + for (std::size_t channel = 4U; channel < 7U; ++channel) { + fault[channel].rms_counts /= 4; + } + return { + sampled_values::make_hold_segment(normal, 2'000U), + sampled_values::make_hold_segment(fault, 800U), + sampled_values::make_hold_segment(normal, 2'000U)}; +} + +bool write_injector_payload( + sampled_values::SampledValueAsdu& asdu, + const sampled_values::InjectorSample& sample) noexcept { + if (asdu.sample_payload.size() != kPayloadBytes) { + return false; + } + + const auto payload = std::span{ + asdu.sample_payload.data(), asdu.sample_payload.size()}; + for (std::size_t channel = 0U; + channel < sampled_values::injector_channel_count; + ++channel) { + if (!sampled_values::SampledValuesPayloadWriter::write_int32_quality_pair( + payload, + channel, + sample.values[channel], + sample.qualities[channel])) { + return false; + } + } + return true; +} + +void sv_timer_callback(void* argument) { + (void)argument; + if (g_publisher_task != nullptr) { + xTaskNotifyGive(g_publisher_task); + } +} + +void control_task(void* argument) { + (void)argument; + std::array line{}; + std::size_t length{}; + + ESP_LOGI( + kTag, + "Control plane ready on primary stdio; send one JSON command per line"); + print_capabilities(); + + while (true) { + bool consumed_character = false; + while (true) { + const auto character = std::getchar(); + if (character == EOF) { + std::clearerr(stdin); + break; + } + consumed_character = true; + if (character == '\r') { + continue; + } + if (character == '\n') { + if (length == 0U) { + continue; + } + const auto parsed = sampled_values::parse_injector_control_command( + std::string_view{line.data(), length}); + length = 0U; + if (!parsed.success()) { + std::printf( + "ARCTRL {\"schemaVersion\":\"arstack-sv-injector-control-v1\"," + "\"command\":\"parse\",\"ok\":false,\"parseStatus\":%u}\n", + static_cast(parsed.status)); + std::fflush(stdout); + continue; + } + if (xQueueSend( + g_control_queue, + &parsed.command, + pdMS_TO_TICKS(100)) != pdPASS) { + print_control_error(command_name(parsed.command.kind), "control-queue-full"); + } + continue; + } + + if (length + 1U >= line.size()) { + length = 0U; + print_control_error("parse", "line-too-long"); + continue; + } + line[length] = static_cast(character); + ++length; + } + + if (!consumed_character) { + vTaskDelay(pdMS_TO_TICKS(10)); + } else { + taskYIELD(); + } + } +} + +void publisher_task(void* argument) { + (void)argument; + g_publisher_task = xTaskGetCurrentTaskHandle(); + + std::array source_mac{}; + while ((xEventGroupGetBits(g_link_events) & kEthernetLinkUpBit) == 0U) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + ESP_ERROR_CHECK(esp_eth_ioctl(g_eth_handle, ETH_CMD_G_MAC_ADDR, source_mac.data())); + + auto frame = make_sv_frame(source_mac); + std::array frame_buffer{}; + ports::esp_idf::RawEthernetContext ethernet_context{g_eth_handle}; + const auto raw_port = ports::esp_idf::make_raw_ethernet_port(ethernet_context); + const auto clock = ports::esp_idf::make_monotonic_clock(); + + sampled_values::SampledValuesPublisher publisher( + frame, + frame_buffer, + raw_port, + sampled_values::SampledValuesPublisherConfig{ + kSampleRateHz, + std::uint16_t{kSampleCountWrap}, + 0U, + true}); + + const auto protection_fault_scenario = make_protection_fault_scenario(); + std::optional injector; + std::optional pending_sample; + + if (!publisher.valid()) { + ESP_LOGE(kTag, "SV publisher configuration is invalid"); + g_injector_controller.set_fault(); + vTaskDelete(nullptr); + return; + } + + if (g_injector_controller.configure({ + kSampleRateHz, + sampled_values::InjectorScenarioKind::normal}) != + sampled_values::InjectorControlStatus::ok) { + ESP_LOGE(kTag, "Initial injector configuration failed"); + g_injector_controller.set_fault(); + vTaskDelete(nullptr); + return; + } + + esp_timer_handle_t timer{}; + esp_timer_create_args_t timer_args{}; + timer_args.callback = &sv_timer_callback; + timer_args.dispatch_method = ESP_TIMER_TASK; + timer_args.name = "arstack_sv_4k"; + ESP_ERROR_CHECK(esp_timer_create(&timer_args, &timer)); + ESP_ERROR_CHECK(esp_timer_start_periodic(timer, kPublishIntervalUs)); + + ESP_LOGI( + kTag, + "SV injector CONFIGURED and waiting for control: arm -> start"); + print_status(); + + std::uint64_t timer_notifications{}; + std::uint64_t coalesced_notifications{}; + std::uint64_t report_notifications{}; + + while (true) { + const auto notifications = ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + timer_notifications += notifications; + report_notifications += notifications; + if (notifications > 1U) { + coalesced_notifications += static_cast(notifications - 1U); + } + + sampled_values::InjectorControlCommand command{}; + while (xQueueReceive(g_control_queue, &command, 0U) == pdPASS) { + sampled_values::InjectorControlStatus control_status{ + sampled_values::InjectorControlStatus::ok}; + + switch (command.kind) { + case sampled_values::InjectorControlCommandKind::capabilities: + print_capabilities(); + continue; + case sampled_values::InjectorControlCommandKind::status: + print_status(); + continue; + case sampled_values::InjectorControlCommandKind::stats: { + const auto& stats = publisher.statistics(); + const auto snapshot = g_injector_controller.snapshot(); + std::printf( + "ARCTRL {\"schemaVersion\":\"arstack-sv-injector-control-v1\"," + "\"command\":\"stats\",\"ok\":true,\"state\":\"%s\"," + "\"framesSent\":%" PRIu64 ",\"encodeFailures\":%" PRIu64 "," + "\"transmitFailures\":%" PRIu64 ",\"latePolls\":%" PRIu64 "," + "\"maximumLatenessUs\":%" PRIu64 ",\"timerTicks\":%" PRIu64 "," + "\"coalescedTicks\":%" PRIu64, + state_name(snapshot.state), + stats.frames_sent, + stats.encode_failures, + stats.transmit_failures, + stats.late_polls, + stats.maximum_lateness_us, + timer_notifications, + coalesced_notifications); + if (g_runtime_program.has_value()) { + std::printf( + ",\"sourceMode\":\"%s\",\"sourceRevision\":%u", + runtime_mode_name(g_runtime_program->mode()), + static_cast(g_runtime_program->revision())); + } + std::printf("}\n"); + std::fflush(stdout); + continue; + } + case sampled_values::InjectorControlCommandKind::set_channel: + case sampled_values::InjectorControlCommandKind::ramp_channel: { + if (!g_injector_controller.running() || !injector.has_value() || + !g_runtime_program.has_value()) { + print_control_error(command_name(command.kind), "source-not-editable"); + continue; + } + const auto current_segment = injector->segment_index(); + if (!g_runtime_program->is_hold_segment(current_segment)) { + print_control_error(command_name(command.kind), "source-busy"); + continue; + } + const auto staged = command.kind == + sampled_values::InjectorControlCommandKind::set_channel + ? g_runtime_program->stage_manual_edit( + current_segment, command.channel_edit) + : g_runtime_program->stage_ramp_edit( + current_segment, command.channel_edit); + if (!staged) { + print_control_error(command_name(command.kind), "invalid-runtime-edit"); + continue; + } + print_control_ack(command.kind); + continue; + } + case sampled_values::InjectorControlCommandKind::sequence_begin: { + if (!g_injector_controller.running() || !injector.has_value() || + !g_runtime_program.has_value()) { + print_control_error("sequence-begin", "source-not-editable"); + continue; + } + const auto current_segment = injector->segment_index(); + if (!g_runtime_program->is_hold_segment(current_segment)) { + print_control_error("sequence-begin", "source-busy"); + continue; + } + g_sequence_builder.begin( + g_runtime_program->profile_at(current_segment)); + print_control_ack(command.kind); + continue; + } + case sampled_values::InjectorControlCommandKind::sequence_state_begin: + if (!g_sequence_builder.begin_state( + command.duration_samples, command.transition)) { + print_control_error("sequence-state-begin", "invalid-sequence-state"); + continue; + } + print_control_ack(command.kind); + continue; + case sampled_values::InjectorControlCommandKind::sequence_set_channel: + if (!g_sequence_builder.edit_channel(command.channel_edit)) { + print_control_error("sequence-set-channel", "no-open-sequence-state"); + continue; + } + print_control_ack(command.kind); + continue; + case sampled_values::InjectorControlCommandKind::sequence_state_commit: + if (!g_sequence_builder.commit_state()) { + print_control_error("sequence-state-commit", "no-open-sequence-state"); + continue; + } + print_control_ack(command.kind); + continue; + case sampled_values::InjectorControlCommandKind::sequence_commit: { + if (!g_injector_controller.running() || !injector.has_value() || + !g_runtime_program.has_value() || !g_sequence_builder.ready()) { + print_control_error("sequence-commit", "sequence-not-ready"); + continue; + } + const auto current_segment = injector->segment_index(); + if (!g_runtime_program->is_hold_segment(current_segment)) { + print_control_error("sequence-commit", "source-busy"); + continue; + } + if (!g_runtime_program->stage_sequence( + current_segment, g_sequence_builder.states())) { + print_control_error("sequence-commit", "sequence-stage-failed"); + continue; + } + g_sequence_builder.abort(); + print_control_ack(command.kind); + continue; + } + case sampled_values::InjectorControlCommandKind::sequence_abort: + g_sequence_builder.abort(); + print_control_ack(command.kind); + continue; + case sampled_values::InjectorControlCommandKind::configure: + control_status = g_injector_controller.configure({ + kSampleRateHz, + command.scenario}); + break; + case sampled_values::InjectorControlCommandKind::arm: + control_status = g_injector_controller.arm(); + break; + case sampled_values::InjectorControlCommandKind::start: + control_status = g_injector_controller.start(); + if (control_status == sampled_values::InjectorControlStatus::ok) { + const auto snapshot = g_injector_controller.snapshot(); + publisher.reset(0U); + pending_sample.reset(); + g_runtime_program.reset(); + g_sequence_builder.abort(); + if (snapshot.configuration.scenario == + sampled_values::InjectorScenarioKind::protection_fault) { + injector.emplace( + std::span{ + protection_fault_scenario}, + kSampleRateHz, + true); + } else { + g_runtime_program.emplace( + sampled_values::make_balanced_4i4v_profile()); + injector.emplace( + g_runtime_program->segments(), + kSampleRateHz, + true); + } + if (!injector->valid()) { + g_injector_controller.set_fault(); + injector.reset(); + g_runtime_program.reset(); + print_control_error("start", "invalid-injector-scenario"); + continue; + } + } + break; + case sampled_values::InjectorControlCommandKind::stop: + control_status = g_injector_controller.stop(); + if (control_status == sampled_values::InjectorControlStatus::ok) { + pending_sample.reset(); + injector.reset(); + g_runtime_program.reset(); + g_sequence_builder.abort(); + publisher.reset(0U); + } + break; + } + + if (control_status == sampled_values::InjectorControlStatus::ok) { + print_control_ack(command.kind); + } else if (control_status == + sampled_values::InjectorControlStatus::invalid_configuration) { + print_control_error(command_name(command.kind), "invalid-configuration"); + } else { + print_control_error(command_name(command.kind), "invalid-state"); + } + } + + if (!g_injector_controller.running()) { + continue; + } + if ((xEventGroupGetBits(g_link_events) & kEthernetLinkUpBit) == 0U) { + continue; + } + if (!injector.has_value()) { + ESP_LOGE(kTag, "RUNNING state without active deterministic injector"); + g_injector_controller.set_fault(); + print_control_error("runtime", "missing-injector"); + continue; + } + + if (!pending_sample.has_value()) { + sampled_values::InjectorSample next_sample{}; + if (!injector->step(next_sample)) { + ESP_LOGE(kTag, "Deterministic injector stopped unexpectedly"); + g_injector_controller.set_fault(); + injector.reset(); + g_runtime_program.reset(); + g_sequence_builder.abort(); + print_control_error("runtime", "injector-finished"); + continue; + } + pending_sample = next_sample; + + const auto expected_sample_count = static_cast( + next_sample.sample_index % static_cast(kSampleCountWrap)); + if (publisher.next_sample_count() != expected_sample_count) { + ESP_LOGE( + kTag, + "Logical sample/smpCnt divergence: sample=%" PRIu64 " expected=%u publisher=%u", + next_sample.sample_index, + static_cast(expected_sample_count), + static_cast(publisher.next_sample_count())); + g_injector_controller.set_fault(); + injector.reset(); + g_runtime_program.reset(); + g_sequence_builder.abort(); + pending_sample.reset(); + print_control_error("runtime", "sample-counter-divergence"); + continue; + } + + if (!write_injector_payload(frame.pdu.asdus.front(), next_sample)) { + ESP_LOGE(kTag, "Deterministic payload update failed"); + g_injector_controller.set_fault(); + injector.reset(); + g_runtime_program.reset(); + g_sequence_builder.abort(); + pending_sample.reset(); + print_control_error("runtime", "payload-write-failed"); + continue; + } + } + + const auto result = publisher.poll(clock.monotonic_us()); + if (result.status == sampled_values::SampledValuesPublishStatus::sent) { + pending_sample.reset(); + } else if (result.status != sampled_values::SampledValuesPublishStatus::not_due) { + ESP_LOGE( + kTag, + "SV publish fault: status=%u io=%u encode=%u", + static_cast(result.status), + static_cast(result.io_status), + static_cast(result.encode_status)); + g_injector_controller.set_fault(); + injector.reset(); + g_runtime_program.reset(); + g_sequence_builder.abort(); + pending_sample.reset(); + print_control_error("runtime", "publish-failed"); + continue; + } + + if (report_notifications >= kSampleRateHz) { + report_notifications -= kSampleRateHz; + const auto& stats = publisher.statistics(); + const auto snapshot = g_injector_controller.snapshot(); + ESP_LOGI( + kTag, + "QA state=%s run=%" PRIu64 " sample=%" PRIu64 + " sent=%" PRIu64 " txFail=%" PRIu64 " encFail=%" PRIu64 + " late=%" PRIu64 " maxLateUs=%" PRIu64 + " timerTicks=%" PRIu64 " coalesced=%" PRIu64 + " heap=%u minHeap=%u", + state_name(snapshot.state), + snapshot.run_sequence, + injector.has_value() ? injector->sample_index() : 0U, + stats.frames_sent, + stats.transmit_failures, + stats.encode_failures, + stats.late_polls, + stats.maximum_lateness_us, + timer_notifications, + coalesced_notifications, + static_cast(esp_get_free_heap_size()), + static_cast(esp_get_minimum_free_heap_size())); + } + } +} + +} // namespace + +extern "C" void app_main(void) { + g_link_events = xEventGroupCreate(); + if (g_link_events == nullptr) { + ESP_LOGE(kTag, "Failed to create Ethernet link event group"); + return; + } + + g_control_queue = xQueueCreate( + static_cast(kControlQueueDepth), + sizeof(sampled_values::InjectorControlCommand)); + if (g_control_queue == nullptr) { + ESP_LOGE(kTag, "Failed to create injector control queue"); + return; + } + + ESP_ERROR_CHECK(esp_event_loop_create_default()); + ESP_ERROR_CHECK(initialize_ethernet(&g_eth_handle)); + ESP_ERROR_CHECK(esp_event_handler_register( + ETH_EVENT, + ESP_EVENT_ANY_ID, + ðernet_event_handler, + nullptr)); + ESP_ERROR_CHECK(esp_eth_start(g_eth_handle)); + + const auto publisher_created = xTaskCreate( + &publisher_task, + "arstack_sv_pub", + kPublisherTaskStackBytes, + nullptr, + kPublisherTaskPriority, + nullptr); + if (publisher_created != pdPASS) { + ESP_LOGE(kTag, "Failed to create SV publisher task"); + return; + } + + const auto control_created = xTaskCreate( + &control_task, + "arstack_sv_ctl", + kControlTaskStackBytes, + nullptr, + kControlTaskPriority, + nullptr); + if (control_created != pdPASS) { + ESP_LOGE(kTag, "Failed to create SV control task"); + } +} diff --git a/ports/esp_idf/sv_trial_app/partitions.csv b/ports/esp_idf/sv_trial_app/partitions.csv new file mode 100644 index 000000000..87077131c --- /dev/null +++ b/ports/esp_idf/sv_trial_app/partitions.csv @@ -0,0 +1,5 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x6000, +phy_init, data, phy, 0xf000, 0x1000, +factory, app, factory, 0x10000, 1M, +brcb_state, 0x40, 0x01, , 0x3000, diff --git a/ports/esp_idf/sv_trial_app/sdkconfig.defaults b/ports/esp_idf/sv_trial_app/sdkconfig.defaults new file mode 100644 index 000000000..ac3c9b1b7 --- /dev/null +++ b/ports/esp_idf/sv_trial_app/sdkconfig.defaults @@ -0,0 +1,16 @@ +# The linked arstack61850 component is a hard no-exception profile. +CONFIG_COMPILER_CXX_EXCEPTIONS=n + +# First physical proof uses the ESP32-P4 internal EMAC/RMII path. +CONFIG_ETH_USE_ESP32_EMAC=y + +# Reserve two raw BRCB journal banks plus one destructive latency-probe erase unit. +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" + +# Board management/control uses the onboard Type-C USB-to-UART bridge wired to +# ESP32-P4 UART0 (TX GPIO37 / RX GPIO38). Keep UART0 as primary stdio so the +# same COM port used for flashing/monitoring can also carry line-delimited JSON +# injector commands after boot. +CONFIG_ESP_CONSOLE_UART_DEFAULT=y +CONFIG_ESP_CONSOLE_UART_BAUDRATE=115200 diff --git a/src/acse/association_span.cpp b/src/acse/association_span.cpp new file mode 100644 index 000000000..c7f1ea4d4 --- /dev/null +++ b/src/acse/association_span.cpp @@ -0,0 +1,417 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/acse/association_span.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::acse { +namespace { + +constexpr std::array kMmsApplicationContext{ + 0x28U, 0xCAU, 0x22U, 0x02U, 0x03U}; + +constexpr std::array kDefaultMmsInitiateRequest{ + 0xA8U, 0x26U, + 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, + 0x81U, 0x01U, 0x0AU, + 0x82U, 0x01U, 0x0AU, + 0x83U, 0x01U, 0x05U, + 0xA4U, 0x16U, + 0x80U, 0x01U, 0x01U, + 0x81U, 0x03U, 0x05U, 0xF1U, 0x00U, + 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, + 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U}; + +constexpr std::array kDefaultMmsInitiateResponse{ + 0xA9U, 0x26U, + 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, + 0x81U, 0x01U, 0x0AU, + 0x82U, 0x01U, 0x0AU, + 0x83U, 0x01U, 0x05U, + 0xA4U, 0x16U, + 0x80U, 0x01U, 0x01U, + 0x81U, 0x03U, 0x05U, 0xF1U, 0x00U, + 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, + 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U}; + +// Byte-identical to AcseAssociationCodec::encode_aare(default_accept_aare()). +constexpr std::array kDefaultAcceptAare{ + 0x61U, 0x4AU, + 0xA1U, 0x07U, 0x06U, 0x05U, 0x28U, 0xCAU, 0x22U, 0x02U, 0x03U, + 0xA2U, 0x03U, 0x02U, 0x01U, 0x00U, + 0xA3U, 0x05U, 0xA1U, 0x03U, 0x02U, 0x01U, 0x00U, + 0xBEU, 0x33U, + 0x28U, 0x31U, + 0x06U, 0x02U, 0x51U, 0x01U, + 0x02U, 0x01U, 0x03U, + 0xA0U, 0x28U, + 0xA9U, 0x26U, + 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, + 0x81U, 0x01U, 0x0AU, + 0x82U, 0x01U, 0x0AU, + 0x83U, 0x01U, 0x05U, + 0xA4U, 0x16U, + 0x80U, 0x01U, 0x01U, + 0x81U, 0x03U, 0x05U, 0xF1U, 0x00U, + 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, + 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U}; + +[[nodiscard]] bool valid_oid( + const std::span value, + const bool allow_empty = false) noexcept { + return (allow_empty || !value.empty()) && value.size() <= AcseSpanCodec::maximum_oid_bytes; +} + +[[nodiscard]] bool decode_explicit_oid( + const asn1::BerTlvView& field, + std::span& oid, + const bool allow_empty = false) noexcept { + oid = {}; + if (!field.constructed) { + return false; + } + asn1::BerTlvView inner; + if (!asn1::BerSpanReader::try_read_exact(field.value, inner) || + inner.tag_class != asn1::BerClass::universal || + inner.tag_number != 6 || inner.constructed || + !valid_oid(inner.value, allow_empty)) { + return false; + } + oid = inner.value; + return true; +} + +[[nodiscard]] bool decode_explicit_integer( + const asn1::BerTlvView& field, + std::uint32_t& value) noexcept { + value = 0U; + if (!field.constructed) { + return false; + } + asn1::BerTlvView inner; + if (!asn1::BerSpanReader::try_read_exact(field.value, inner) || + inner.tag_class != asn1::BerClass::universal || + inner.tag_number != 2 || inner.constructed) { + return false; + } + const auto decoded = asn1::BerSpanReader::read_uint32(inner); + if (!decoded) { + return false; + } + value = *decoded; + return true; +} + +[[nodiscard]] bool decode_external( + const asn1::BerTlvView& user_information, + AcseExternalView& external) noexcept { + external = {}; + if (!user_information.constructed) { + return false; + } + + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(user_information.value, outer) || + outer.tag_class != asn1::BerClass::universal || + outer.tag_number != 8 || !outer.constructed) { + return false; + } + + bool saw_direct = false; + bool saw_indirect = false; + bool saw_encoding = false; + std::size_t offset = 0U; + while (offset < outer.value.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv(outer.value, offset, field)) { + external = {}; + return false; + } + + if (field.tag_class == asn1::BerClass::universal && + field.tag_number == 6 && !field.constructed) { + if (saw_direct || !valid_oid(field.value)) { + external = {}; + return false; + } + external.direct_reference = field.value; + saw_direct = true; + } else if (field.tag_class == asn1::BerClass::universal && + field.tag_number == 2 && !field.constructed) { + if (saw_indirect) { + external = {}; + return false; + } + const auto value = asn1::BerSpanReader::read_uint32(field); + if (!value) { + external = {}; + return false; + } + external.indirect_reference = *value; + saw_indirect = true; + } else if (field.tag_class == asn1::BerClass::context_specific && + field.tag_number == 0 && field.constructed) { + if (saw_encoding || field.value.size() > AcseSpanCodec::maximum_acse_bytes) { + external = {}; + return false; + } + external.single_asn1_type = field.value; + saw_encoding = true; + } else { + external = {}; + return false; + } + } + return saw_direct && saw_indirect && saw_encoding; +} + +[[nodiscard]] bool request_is_valid_for_response( + const AssociationRequestView& request) noexcept { + return request.session.kind == osi::SessionWireKind::connect && + request.presentation.mode_selector != 0U && + request.presentation.context_count != 0U && + request.acse_presentation_context_id != 0U && + request.mms_presentation_context_id != 0U && + request.acse_presentation_context_id == request.presentation.user_data.context_id; +} + +} // namespace + +std::span AcseSpanCodec::mms_application_context_name() noexcept { + return kMmsApplicationContext; +} + +std::span AcseSpanCodec::default_mms_initiate_request() noexcept { + return kDefaultMmsInitiateRequest; +} + +std::span AcseSpanCodec::default_mms_initiate_response() noexcept { + return kDefaultMmsInitiateResponse; +} + +bool AcseSpanCodec::try_decode_aarq_view( + const std::span bytes, + AcseAarqView& aarq) noexcept { + aarq = {}; + if (bytes.empty() || bytes.size() > maximum_acse_bytes) { + return false; + } + + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(bytes, outer) || + outer.tag_class != asn1::BerClass::application || + outer.tag_number != 0 || !outer.constructed) { + return false; + } + + bool saw_application = false; + bool saw_called_ap = false; + bool saw_called_ae = false; + bool saw_calling_ap = false; + bool saw_calling_ae = false; + bool saw_user_information = false; + std::size_t offset = 0U; + while (offset < outer.value.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv(outer.value, offset, field) || + field.tag_class != asn1::BerClass::context_specific) { + aarq = {}; + return false; + } + + switch (field.tag_number) { + case 1: + if (saw_application || + !decode_explicit_oid(field, aarq.application_context_name)) { + aarq = {}; + return false; + } + saw_application = true; + break; + case 2: + if (saw_called_ap || + !decode_explicit_oid(field, aarq.called_ap_title, true)) { + aarq = {}; + return false; + } + saw_called_ap = true; + break; + case 3: { + if (saw_called_ae) { + aarq = {}; + return false; + } + std::uint32_t value{}; + if (!decode_explicit_integer(field, value)) { + aarq = {}; + return false; + } + aarq.called_ae_qualifier = value; + saw_called_ae = true; + break; + } + case 6: + if (saw_calling_ap || + !decode_explicit_oid(field, aarq.calling_ap_title, true)) { + aarq = {}; + return false; + } + saw_calling_ap = true; + break; + case 7: { + if (saw_calling_ae) { + aarq = {}; + return false; + } + std::uint32_t value{}; + if (!decode_explicit_integer(field, value)) { + aarq = {}; + return false; + } + aarq.calling_ae_qualifier = value; + saw_calling_ae = true; + break; + } + case 30: + if (saw_user_information || !decode_external(field, aarq.user_information)) { + aarq = {}; + return false; + } + saw_user_information = true; + break; + default: + aarq = {}; + return false; + } + } + + if (!saw_application || !saw_user_information) { + aarq = {}; + return false; + } + return true; +} + +bool AcseSpanCodec::try_decode_association_request_view( + const std::span bytes, + AssociationRequestView& request) noexcept { + request = {}; + if (!osi::SessionSpanCodec::try_decode_view(bytes, request.session) || + request.session.kind != osi::SessionWireKind::connect || + !osi::PresentationSpanCodec::try_decode_cp_view( + request.session.user_data, request.presentation) || + !try_decode_aarq_view( + request.presentation.user_data.single_asn1_type, request.aarq)) { + request = {}; + return false; + } + + if (!request.presentation.try_context_id_for_abstract_syntax( + osi::PresentationSpanCodec::acse_abstract_syntax_name(), + request.acse_presentation_context_id) || + !request.presentation.try_context_id_for_abstract_syntax( + osi::PresentationSpanCodec::mms_abstract_syntax_name(), + request.mms_presentation_context_id) || + !request_is_valid_for_response(request)) { + request = {}; + return false; + } + return true; +} + +wire::EncodeResult AcseSpanCodec::encode_default_accept_aare_into( + const std::span destination) noexcept { + if (destination.size() < kDefaultAcceptAare.size()) { + return { + wire::EncodeStatus::buffer_too_small, + 0U, + kDefaultAcceptAare.size()}; + } + std::copy( + kDefaultAcceptAare.begin(), + kDefaultAcceptAare.end(), + destination.begin()); + return { + wire::EncodeStatus::ok, + kDefaultAcceptAare.size(), + kDefaultAcceptAare.size()}; +} + +std::optional AcseSpanCodec::accept_response_size( + const AssociationRequestView& request) noexcept { + if (!request_is_valid_for_response(request)) { + return std::nullopt; + } + + const auto cpa_size = osi::PresentationSpanCodec::cpa_accepting_size( + request.presentation, default_accept_aare_size()); + if (!cpa_size || *cpa_size > std::numeric_limits::max()) { + return std::nullopt; + } + + const auto user_parameter_bytes = 2U + *cpa_size; + if (user_parameter_bytes > std::numeric_limits::max() || + request.session.parameter_bytes.size() > + std::numeric_limits::max() - user_parameter_bytes) { + return std::nullopt; + } + const auto body_bytes = request.session.parameter_bytes.size() + user_parameter_bytes; + return body_bytes + 2U; +} + +wire::EncodeResult AcseSpanCodec::build_accept_response_into( + const AssociationRequestView& request, + const std::span destination) noexcept { + const auto required = accept_response_size(request); + if (!required) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + std::array aare{}; + const auto aare_result = encode_default_accept_aare_into(aare); + const auto cpa_size = osi::PresentationSpanCodec::cpa_accepting_size( + request.presentation, aare_result.bytes_written); + if (!aare_result.success() || !cpa_size || + *cpa_size > std::numeric_limits::max()) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + + const auto body_bytes = *required - 2U; + destination[0] = osi::SessionSpanCodec::accept_code; + destination[1] = static_cast(body_bytes); + std::copy( + request.session.parameter_bytes.begin(), + request.session.parameter_bytes.end(), + destination.begin() + 2); + + std::size_t offset = 2U + request.session.parameter_bytes.size(); + destination[offset++] = osi::SessionSpanCodec::user_data_parameter; + destination[offset++] = static_cast(*cpa_size); + + const auto cpa_result = osi::PresentationSpanCodec::encode_cpa_accepting_into( + request.presentation, + std::span{aare}.first(aare_result.bytes_written), + destination.subspan(offset, *cpa_size)); + if (!cpa_result.success() || cpa_result.bytes_written != *cpa_size) { + return {cpa_result.status, 0U, *required}; + } + offset += cpa_result.bytes_written; + + if (offset != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, *required, *required}; +} + +} // namespace ar::iec61850::acse diff --git a/src/asn1/ber_span_reader.cpp b/src/asn1/ber_span_reader.cpp new file mode 100644 index 000000000..65b4e5aa6 --- /dev/null +++ b/src/asn1/ber_span_reader.cpp @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/asn1/ber_span_reader.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::asn1 { + +bool BerSpanReader::try_read_tlv( + const std::span source, + std::size_t& offset, + BerTlvView& tlv) noexcept { + tlv = {}; + if (offset >= source.size()) { + return false; + } + + auto cursor = offset; + const auto start = cursor; + const auto encoded_tag = source[cursor++]; + std::int32_t tag_number = static_cast(encoded_tag & 0x1FU); + + if (tag_number == 0x1F) { + tag_number = 0; + bool read_any = false; + bool terminated = false; + while (cursor < source.size()) { + const auto byte = source[cursor++]; + read_any = true; + if (tag_number > (1'000'000 >> 7)) { + return false; + } + const auto next_tag = + (static_cast(tag_number) << 7U) | + static_cast(byte & 0x7FU); + tag_number = static_cast(next_tag); + if (tag_number > 1'000'000) { + return false; + } + if ((byte & 0x80U) == 0U) { + terminated = true; + break; + } + } + if (!read_any || !terminated) { + return false; + } + } + + if (cursor >= source.size()) { + return false; + } + + const auto length_byte = source[cursor++]; + std::size_t length = 0U; + if ((length_byte & 0x80U) == 0U) { + length = static_cast(length_byte); + } else { + const auto length_bytes = static_cast(length_byte & 0x7FU); + if (length_bytes == 0U || length_bytes > 4U || + length_bytes > source.size() - cursor) { + return false; + } + for (std::size_t index = 0U; index < length_bytes; ++index) { + length = (length << 8U) | + static_cast(source[cursor++]); + } + } + + if (length > source.size() - cursor) { + return false; + } + + const auto header_bytes = cursor - start; + const auto encoded_bytes = header_bytes + length; + tlv.encoded_tag = encoded_tag; + tlv.tag_class = static_cast((encoded_tag >> 6U) & 0x03U); + tlv.constructed = (encoded_tag & 0x20U) != 0U; + tlv.tag_number = tag_number; + tlv.header_bytes = header_bytes; + tlv.encoded_bytes = encoded_bytes; + tlv.value = source.subspan(cursor, length); + offset = cursor + length; + return true; +} + +bool BerSpanReader::try_read_exact( + const std::span source, + BerTlvView& tlv) noexcept { + std::size_t offset = 0U; + return try_read_tlv(source, offset, tlv) && offset == source.size(); +} + +std::optional BerSpanReader::read_unsigned_integer( + const BerTlvView& tlv) noexcept { + if (tlv.value.size() > sizeof(std::uint64_t)) { + return std::nullopt; + } + + std::uint64_t value = 0U; + for (const auto byte : tlv.value) { + value = (value << 8U) | static_cast(byte); + } + return value; +} + +std::optional BerSpanReader::read_signed_integer( + const BerTlvView& tlv) noexcept { + if (tlv.value.empty()) { + return std::int64_t{0}; + } + if (tlv.value.size() > sizeof(std::int64_t)) { + return std::nullopt; + } + + std::uint64_t raw = 0U; + for (const auto byte : tlv.value) { + raw = (raw << 8U) | static_cast(byte); + } + if ((tlv.value.front() & 0x80U) != 0U && + tlv.value.size() < sizeof(std::int64_t)) { + const auto used_bits = static_cast(tlv.value.size() * 8U); + raw |= (~std::uint64_t{0}) << used_bits; + } + return std::bit_cast(raw); +} + +std::optional BerSpanReader::read_uint32( + const BerTlvView& tlv) noexcept { + const auto value = read_unsigned_integer(tlv); + if (!value || *value > std::numeric_limits::max()) { + return std::nullopt; + } + return static_cast(*value); +} + +} // namespace ar::iec61850::asn1 diff --git a/src/ethernet/ethernet.cpp b/src/ethernet/ethernet.cpp index b0ae6db1b..5214a99cf 100644 --- a/src/ethernet/ethernet.cpp +++ b/src/ethernet/ethernet.cpp @@ -4,24 +4,35 @@ #include #include -#include +#include #include +#include +#include +#include +#if !defined(ARIEC61850_NO_EXCEPTIONS) +#include #include #include +#include +#endif namespace ar::iec61850::ethernet { namespace { -void write_u16_be(std::span destination, const std::size_t offset, - const std::uint16_t value) { +void write_u16_be( + const std::span destination, + const std::size_t offset, + const std::uint16_t value) noexcept { destination[offset] = static_cast((value >> 8U) & 0xFFU); destination[offset + 1U] = static_cast(value & 0xFFU); } -std::uint16_t read_u16_be(const std::span source, - const std::size_t offset) noexcept { - return static_cast((static_cast(source[offset]) << 8U) | - static_cast(source[offset + 1U])); +std::uint16_t read_u16_be( + const std::span source, + const std::size_t offset) noexcept { + return static_cast( + (static_cast(source[offset]) << 8U) | + static_cast(source[offset + 1U])); } int hex_value(const char ch) noexcept { @@ -39,20 +50,24 @@ int hex_value(const char ch) noexcept { } // namespace -MacAddress::MacAddress(const std::span bytes) { - if (bytes.size() != bytes_.size()) { - throw std::invalid_argument("A MAC address must contain exactly 6 bytes."); +bool MacAddress::try_from_bytes( + const std::span bytes, + MacAddress& address) noexcept { + if (bytes.size() != address.bytes_.size()) { + address = {}; + return false; } - std::copy(bytes.begin(), bytes.end(), bytes_.begin()); + std::copy(bytes.begin(), bytes.end(), address.bytes_.begin()); + return true; } -MacAddress MacAddress::parse(const std::string& text) { - MacAddress address; - if (!try_parse(text, address)) { - throw std::invalid_argument("Invalid MAC address '" + text + "'."); +#if !defined(ARIEC61850_NO_EXCEPTIONS) +MacAddress::MacAddress(const std::span bytes) { + if (!try_from_bytes(bytes, *this)) { + throw std::invalid_argument("A MAC address must contain exactly 6 bytes."); } - return address; } +#endif bool MacAddress::try_parse(const std::string& text, MacAddress& address) noexcept { std::string normalized; @@ -67,6 +82,7 @@ bool MacAddress::try_parse(const std::string& text, MacAddress& address) noexcep --end; } if (begin == end) { + address = {}; return false; } @@ -74,6 +90,7 @@ bool MacAddress::try_parse(const std::string& text, MacAddress& address) noexcep normalized.push_back(*it == '-' ? ':' : *it); } if (normalized.size() != 17U) { + address = {}; return false; } @@ -81,11 +98,13 @@ bool MacAddress::try_parse(const std::string& text, MacAddress& address) noexcep for (std::size_t index = 0; index < bytes.size(); ++index) { const auto offset = index * 3U; if (index != bytes.size() - 1U && normalized[offset + 2U] != ':') { + address = {}; return false; } const auto high = hex_value(normalized[offset]); const auto low = hex_value(normalized[offset + 1U]); if (high < 0 || low < 0) { + address = {}; return false; } bytes[index] = static_cast((high << 4) | low); @@ -95,11 +114,30 @@ bool MacAddress::try_parse(const std::string& text, MacAddress& address) noexcep return true; } -void MacAddress::copy_to(const std::span destination) const { +#if !defined(ARIEC61850_NO_EXCEPTIONS) +MacAddress MacAddress::parse(const std::string& text) { + MacAddress address; + if (!try_parse(text, address)) { + throw std::invalid_argument("Invalid MAC address '" + text + "'."); + } + return address; +} +#endif + +bool MacAddress::try_copy_to( + const std::span destination) const noexcept { if (destination.size() < bytes_.size()) { - throw std::invalid_argument("Destination span must be at least 6 bytes."); + return false; } std::copy(bytes_.begin(), bytes_.end(), destination.begin()); + return true; +} + +#if !defined(ARIEC61850_NO_EXCEPTIONS) +void MacAddress::copy_to(const std::span destination) const { + if (!try_copy_to(destination)) { + throw std::invalid_argument("Destination span must be at least 6 bytes."); + } } std::string MacAddress::to_string() const { @@ -113,20 +151,34 @@ std::string MacAddress::to_string() const { } return stream.str(); } +#endif -std::uint16_t VlanTag::to_tag_control_information() const { - if (priority_code_point > 7U) { - throw std::out_of_range("VLAN priority must be 0..7."); +bool VlanTag::try_to_tag_control_information(std::uint16_t& tci) const noexcept { + if (priority_code_point > 7U || vlan_id > 4094U) { + tci = 0U; + return false; } - if (vlan_id > 4094U) { + + const auto value = + (static_cast(priority_code_point) << 13U) | + (drop_eligible ? 0x1000U : 0U) | + static_cast(vlan_id); + tci = static_cast(value); + return true; +} + +#if !defined(ARIEC61850_NO_EXCEPTIONS) +std::uint16_t VlanTag::to_tag_control_information() const { + std::uint16_t tci{}; + if (!try_to_tag_control_information(tci)) { + if (priority_code_point > 7U) { + throw std::out_of_range("VLAN priority must be 0..7."); + } throw std::out_of_range("VLAN ID must be 0..4094."); } - - const auto tci = (static_cast(priority_code_point) << 13U) | - (drop_eligible ? 0x1000U : 0U) | - static_cast(vlan_id); - return static_cast(tci); + return tci; } +#endif VlanTag VlanTag::from_tag_control_information(const std::uint16_t tci) noexcept { return { @@ -135,38 +187,81 @@ VlanTag VlanTag::from_tag_control_information(const std::uint16_t tci) noexcept static_cast(tci & 0x0FFFU)}; } -std::vector EthernetFrameCodec::encode(const EthernetFrame& frame) { - const std::size_t header_length = frame.vlan ? 18U : 14U; - std::vector bytes(header_length + frame.payload.size()); - const std::span span{bytes}; +std::optional EthernetFrameCodec::encoded_size( + const EthernetFrame& frame) noexcept { + if (frame.vlan.has_value()) { + std::uint16_t tci{}; + if (!frame.vlan->try_to_tag_control_information(tci)) { + return std::nullopt; + } + } + + const std::size_t header_length = frame.vlan.has_value() ? 18U : 14U; + if (frame.payload.size() > std::numeric_limits::max() - header_length) { + return std::nullopt; + } + return header_length + frame.payload.size(); +} - frame.destination.copy_to(span.first(6U)); - frame.source.copy_to(span.subspan(6U, 6U)); +wire::EncodeResult EthernetFrameCodec::encode_into( + const EthernetFrame& frame, + const std::span destination) noexcept { + const auto required = encoded_size(frame); + if (!required) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + std::copy(frame.destination.bytes().begin(), frame.destination.bytes().end(), destination.begin()); + std::copy( + frame.source.bytes().begin(), + frame.source.bytes().end(), + destination.begin() + 6); std::size_t offset = 12U; - if (frame.vlan) { - write_u16_be(span, offset, vlan_tag_ethertype); - write_u16_be(span, offset + 2U, frame.vlan->to_tag_control_information()); + if (frame.vlan.has_value()) { + std::uint16_t tci{}; + if (!frame.vlan->try_to_tag_control_information(tci)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + write_u16_be(destination, offset, vlan_tag_ethertype); + write_u16_be(destination, offset + 2U, tci); offset += 4U; } - write_u16_be(span, offset, frame.ether_type); + write_u16_be(destination, offset, frame.ether_type); offset += 2U; - std::copy(frame.payload.begin(), frame.payload.end(), bytes.begin() + static_cast(offset)); - return bytes; + std::copy( + frame.payload.begin(), + frame.payload.end(), + destination.begin() + static_cast(offset)); + offset += frame.payload.size(); + return {wire::EncodeStatus::ok, offset, *required}; } -bool EthernetFrameCodec::try_decode(const std::span bytes, - EthernetFrame& frame) noexcept { - frame = {}; - if (bytes.size() < 14U) { - return false; +#if !defined(ARIEC61850_NO_EXCEPTIONS) +std::vector EthernetFrameCodec::encode(const EthernetFrame& frame) { + const auto required = encoded_size(frame); + if (!required) { + throw std::out_of_range("Ethernet frame exceeds the supported wire range."); } + std::vector bytes(*required); + const auto result = encode_into(frame, bytes); + if (!result.success() || result.bytes_written != bytes.size()) { + throw std::runtime_error("Failed to encode Ethernet frame into sized buffer."); + } + return bytes; +} - try { - frame.destination = MacAddress(bytes.first(6U)); - frame.source = MacAddress(bytes.subspan(6U, 6U)); - } catch (...) { +bool EthernetFrameCodec::try_decode( + const std::span bytes, + EthernetFrame& frame) noexcept { + frame = {}; + if (bytes.size() < 14U || + !MacAddress::try_from_bytes(bytes.first(6U), frame.destination) || + !MacAddress::try_from_bytes(bytes.subspan(6U, 6U), frame.source)) { return false; } @@ -180,8 +275,6 @@ bool EthernetFrameCodec::try_decode(const std::span bytes, } const auto decoded_vlan = VlanTag::from_tag_control_information(read_u16_be(bytes, 14U)); - // IEEE 802.1Q reserves VID 4095. Keep try_decode closed under encode: - // every successfully decoded VlanTag must be valid for re-encoding. if (decoded_vlan.vlan_id > 4094U) { return false; } @@ -192,27 +285,65 @@ bool EthernetFrameCodec::try_decode(const std::span bytes, frame.ether_type = ether_type; frame.vlan = vlan; - frame.payload.assign(bytes.begin() + static_cast(payload_offset), bytes.end()); + frame.payload.assign( + bytes.begin() + static_cast(payload_offset), + bytes.end()); return true; } +#endif +std::optional ProcessBusFrameCodec::encoded_payload_size( + const std::span apdu) noexcept { + if (apdu.size() > + static_cast(std::numeric_limits::max()) - header_length) { + return std::nullopt; + } + return header_length + apdu.size(); +} + +wire::EncodeResult ProcessBusFrameCodec::encode_payload_into( + const std::uint16_t app_id, + const std::span apdu, + const std::span destination, + const std::uint16_t reserved1, + const std::uint16_t reserved2) noexcept { + const auto required = encoded_payload_size(apdu); + if (!required) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + const auto declared_length = static_cast(*required); + write_u16_be(destination, 0U, app_id); + write_u16_be(destination, 2U, declared_length); + write_u16_be(destination, 4U, reserved1); + write_u16_be(destination, 6U, reserved2); + std::copy( + apdu.begin(), + apdu.end(), + destination.begin() + static_cast(header_length)); + return {wire::EncodeStatus::ok, *required, *required}; +} + +#if !defined(ARIEC61850_NO_EXCEPTIONS) std::vector ProcessBusFrameCodec::encode_payload( const std::uint16_t app_id, const std::span apdu, const std::uint16_t reserved1, const std::uint16_t reserved2) { - if (apdu.size() > std::numeric_limits::max() - header_length) { + const auto required = encoded_payload_size(apdu); + if (!required) { throw std::out_of_range("Process-bus APDU exceeds the 16-bit declared length."); } - const auto declared_length = static_cast(header_length + apdu.size()); - std::vector bytes(declared_length); - const std::span span{bytes}; - write_u16_be(span, 0U, app_id); - write_u16_be(span, 2U, declared_length); - write_u16_be(span, 4U, reserved1); - write_u16_be(span, 6U, reserved2); - std::copy(apdu.begin(), apdu.end(), bytes.begin() + static_cast(header_length)); + std::vector bytes(*required); + const auto result = encode_payload_into( + app_id, apdu, bytes, reserved1, reserved2); + if (!result.success() || result.bytes_written != bytes.size()) { + throw std::runtime_error("Failed to encode process-bus payload into sized buffer."); + } return bytes; } @@ -225,12 +356,17 @@ EthernetFrame ProcessBusFrameCodec::encode_ethernet_frame( const std::optional vlan, const std::uint16_t reserved1, const std::uint16_t reserved2) { - return {destination, source, ether_type, vlan, - encode_payload(app_id, apdu, reserved1, reserved2)}; + return { + destination, + source, + ether_type, + vlan, + encode_payload(app_id, apdu, reserved1, reserved2)}; } -bool ProcessBusFrameCodec::try_decode(const EthernetFrame& ethernet, - ProcessBusFrame& frame) noexcept { +bool ProcessBusFrameCodec::try_decode( + const EthernetFrame& ethernet, + ProcessBusFrame& frame) noexcept { frame = {}; if (ethernet.payload.size() < header_length) { return false; @@ -256,9 +392,11 @@ bool ProcessBusFrameCodec::try_decode(const EthernetFrame& ethernet, frame.declared_length = declared_length; frame.reserved1 = reserved1; frame.reserved2 = reserved2; - frame.apdu.assign(payload.begin() + static_cast(header_length), - payload.begin() + static_cast(header_length + declared_apdu_length)); + frame.apdu.assign( + payload.begin() + static_cast(header_length), + payload.begin() + static_cast(header_length + declared_apdu_length)); return true; } +#endif -} // namespace ar::iec61850::ethernet \ No newline at end of file +} // namespace ar::iec61850::ethernet diff --git a/src/ethernet/ethernet_encode.cpp b/src/ethernet/ethernet_encode.cpp new file mode 100644 index 000000000..1b139c3f6 --- /dev/null +++ b/src/ethernet/ethernet_encode.cpp @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/ethernet/ethernet.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::ethernet { +namespace { + +void write_u16_be( + const std::span destination, + const std::size_t offset, + const std::uint16_t value) noexcept { + destination[offset] = static_cast((value >> 8U) & 0xFFU); + destination[offset + 1U] = static_cast(value & 0xFFU); +} + +} // namespace + +bool MacAddress::try_from_bytes( + const std::span bytes, + MacAddress& address) noexcept { + if (bytes.size() != address.bytes().size()) { + address = {}; + return false; + } + std::array copy{}; + std::copy(bytes.begin(), bytes.end(), copy.begin()); + address = MacAddress{copy}; + return true; +} + +bool MacAddress::try_copy_to( + const std::span destination) const noexcept { + if (destination.size() < bytes().size()) { + return false; + } + std::copy(bytes().begin(), bytes().end(), destination.begin()); + return true; +} + +bool VlanTag::try_to_tag_control_information(std::uint16_t& tci) const noexcept { + if (priority_code_point > 7U || vlan_id > 4094U) { + tci = 0U; + return false; + } + + const auto value = + (static_cast(priority_code_point) << 13U) | + (drop_eligible ? 0x1000U : 0U) | + static_cast(vlan_id); + tci = static_cast(value); + return true; +} + +VlanTag VlanTag::from_tag_control_information(const std::uint16_t tci) noexcept { + return { + static_cast((tci >> 13U) & 0x07U), + (tci & 0x1000U) != 0U, + static_cast(tci & 0x0FFFU)}; +} + +std::optional EthernetFrameCodec::encoded_size( + const EthernetFrame& frame) noexcept { + if (frame.vlan.has_value()) { + std::uint16_t tci{}; + if (!frame.vlan->try_to_tag_control_information(tci)) { + return std::nullopt; + } + } + + const std::size_t header_length = frame.vlan.has_value() ? 18U : 14U; + if (frame.payload.size() > std::numeric_limits::max() - header_length) { + return std::nullopt; + } + return header_length + frame.payload.size(); +} + +wire::EncodeResult EthernetFrameCodec::encode_into( + const EthernetFrame& frame, + const std::span destination) noexcept { + const auto required = encoded_size(frame); + if (!required) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + std::copy(frame.destination.bytes().begin(), frame.destination.bytes().end(), destination.begin()); + std::copy( + frame.source.bytes().begin(), + frame.source.bytes().end(), + destination.begin() + 6); + + std::size_t offset = 12U; + if (frame.vlan.has_value()) { + std::uint16_t tci{}; + if (!frame.vlan->try_to_tag_control_information(tci)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + write_u16_be(destination, offset, vlan_tag_ethertype); + write_u16_be(destination, offset + 2U, tci); + offset += 4U; + } + + write_u16_be(destination, offset, frame.ether_type); + offset += 2U; + std::copy( + frame.payload.begin(), + frame.payload.end(), + destination.begin() + static_cast(offset)); + offset += frame.payload.size(); + return {wire::EncodeStatus::ok, offset, *required}; +} + +std::optional ProcessBusFrameCodec::encoded_payload_size( + const std::span apdu) noexcept { + if (apdu.size() > + static_cast(std::numeric_limits::max()) - header_length) { + return std::nullopt; + } + return header_length + apdu.size(); +} + +wire::EncodeResult ProcessBusFrameCodec::encode_payload_into( + const std::uint16_t app_id, + const std::span apdu, + const std::span destination, + const std::uint16_t reserved1, + const std::uint16_t reserved2) noexcept { + const auto required = encoded_payload_size(apdu); + if (!required) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + write_u16_be(destination, 0U, app_id); + write_u16_be(destination, 2U, static_cast(*required)); + write_u16_be(destination, 4U, reserved1); + write_u16_be(destination, 6U, reserved2); + std::copy( + apdu.begin(), + apdu.end(), + destination.begin() + static_cast(header_length)); + return {wire::EncodeStatus::ok, *required, *required}; +} + +} // namespace ar::iec61850::ethernet diff --git a/src/goose/frame_encode.cpp b/src/goose/frame_encode.cpp new file mode 100644 index 000000000..dd881ee3a --- /dev/null +++ b/src/goose/frame_encode.cpp @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/goose/frame_codec.hpp" + +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/goose/pdu_codec.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::goose { +namespace { + +void write_u16_be( + const std::span destination, + const std::size_t offset, + const std::uint16_t value) noexcept { + destination[offset] = static_cast((value >> 8U) & 0xFFU); + destination[offset + 1U] = static_cast(value & 0xFFU); +} + +} // namespace + +std::optional GooseFrameCodec::encoded_size( + const GooseFrame& frame) noexcept { + std::uint16_t tci{}; + if (frame.vlan.has_value() && + !frame.vlan->try_to_tag_control_information(tci)) { + return std::nullopt; + } + + const auto apdu_size = GoosePduCodec::encoded_size(frame.pdu); + if (!apdu_size || + *apdu_size > + static_cast(std::numeric_limits::max()) - + ethernet::ProcessBusFrameCodec::header_length) { + return std::nullopt; + } + + const std::size_t ethernet_header = frame.vlan.has_value() ? 18U : 14U; + const std::size_t process_bus_size = + ethernet::ProcessBusFrameCodec::header_length + *apdu_size; + if (process_bus_size > std::numeric_limits::max() - ethernet_header) { + return std::nullopt; + } + return ethernet_header + process_bus_size; +} + +wire::EncodeResult GooseFrameCodec::encode_into( + const GooseFrame& frame, + const std::span destination) noexcept { + const auto required = encoded_size(frame); + if (!required) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + const auto apdu_size = GoosePduCodec::encoded_size(frame.pdu); + if (!apdu_size) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + std::copy( + frame.destination.bytes().begin(), + frame.destination.bytes().end(), + destination.begin()); + std::copy( + frame.source.bytes().begin(), + frame.source.bytes().end(), + destination.begin() + 6); + + std::size_t offset = 12U; + if (frame.vlan.has_value()) { + std::uint16_t tci{}; + if (!frame.vlan->try_to_tag_control_information(tci)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + write_u16_be(destination, offset, ethernet::vlan_tag_ethertype); + write_u16_be(destination, offset + 2U, tci); + offset += 4U; + } + + write_u16_be(destination, offset, ethernet::goose_ethertype); + offset += 2U; + + const auto declared_length = static_cast( + ethernet::ProcessBusFrameCodec::header_length + *apdu_size); + write_u16_be(destination, offset, frame.app_id); + write_u16_be(destination, offset + 2U, declared_length); + write_u16_be(destination, offset + 4U, frame.reserved1); + write_u16_be(destination, offset + 6U, frame.reserved2); + offset += ethernet::ProcessBusFrameCodec::header_length; + + const auto pdu_result = GoosePduCodec::encode_into( + frame.pdu, + destination.subspan(offset, *apdu_size)); + if (!pdu_result.success() || pdu_result.bytes_written != *apdu_size) { + return {pdu_result.status, 0U, *required}; + } + offset += pdu_result.bytes_written; + return {wire::EncodeStatus::ok, offset, *required}; +} + +} // namespace ar::iec61850::goose diff --git a/src/goose/pdu_encode.cpp b/src/goose/pdu_encode.cpp new file mode 100644 index 000000000..e005379b6 --- /dev/null +++ b/src/goose/pdu_encode.cpp @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/goose/pdu_codec.hpp" + +#include "ariec61850/asn1/ber_span_writer.hpp" +#include "ariec61850/mms/data_span_codec.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::goose { +namespace { + +constexpr std::int32_t kGooseApplicationTag = 1; + +[[nodiscard]] std::size_t unsigned_integer_size(std::uint64_t value) noexcept { + std::size_t size = 1U; + while (value > 0xFFU) { + ++size; + value >>= 8U; + } + return size; +} + +[[nodiscard]] std::optional add_size( + const std::size_t left, + const std::optional right) noexcept { + if (!right || *right > std::numeric_limits::max() - left) { + return std::nullopt; + } + return left + *right; +} + +[[nodiscard]] std::optional primitive_size( + const std::int32_t tag, + const std::size_t value_size) noexcept { + return asn1::BerSpanWriter::tlv_size(tag, value_size); +} + +[[nodiscard]] std::optional unsigned_field_size( + const std::int32_t tag, + const std::uint64_t value) noexcept { + return primitive_size(tag, unsigned_integer_size(value)); +} + +[[nodiscard]] bool write_unsigned_value( + asn1::BerSpanWriter& writer, + const std::uint64_t value) noexcept { + const auto size = unsigned_integer_size(value); + for (std::size_t index = size; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte( + static_cast((value >> shift) & 0xFFU))) { + return false; + } + } + return true; +} + +[[nodiscard]] bool write_unsigned_field( + asn1::BerSpanWriter& writer, + const std::int32_t tag, + const std::uint64_t value) noexcept { + const auto size = unsigned_integer_size(value); + return writer.write_tlv_header( + asn1::BerClass::context_specific, false, tag, size) && + write_unsigned_value(writer, value); +} + +[[nodiscard]] bool write_string_field( + asn1::BerSpanWriter& writer, + const std::int32_t tag, + const std::string& value) noexcept { + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, tag, value.size())) { + return false; + } + for (const auto character : value) { + if (!writer.write_byte(static_cast(character))) { + return false; + } + } + return true; +} + +[[nodiscard]] std::optional content_size( + const GoosePdu& pdu) noexcept { + std::array timestamp{}; + if (!pdu.timestamp.try_write_bytes(timestamp)) { + return std::nullopt; + } + + const auto all_data_size = mms::MmsDataSpanCodec::encoded_all_size(pdu.values); + if (!all_data_size || + pdu.values.size() > static_cast( + std::numeric_limits::max())) { + return std::nullopt; + } + + std::size_t total = 0U; + const auto add = [&total](const std::optional size) noexcept { + const auto next = add_size(total, size); + if (!next) { + return false; + } + total = *next; + return true; + }; + + return add(primitive_size(0, pdu.go_cb_ref.size())) && + add(unsigned_field_size(1, pdu.time_allowed_to_live_milliseconds)) && + add(primitive_size(2, pdu.data_set_reference.size())) && + add(primitive_size(3, pdu.go_id.size())) && + add(primitive_size(4, timestamp.size())) && + add(unsigned_field_size(5, pdu.state_number)) && + add(unsigned_field_size(6, pdu.sequence_number)) && + add(primitive_size(7, 1U)) && + add(unsigned_field_size(8, pdu.configuration_revision)) && + add(primitive_size(9, 1U)) && + add(unsigned_field_size( + 10, + static_cast(pdu.values.size()))) && + add(asn1::BerSpanWriter::tlv_size(11, *all_data_size)) + ? std::optional{total} + : std::nullopt; +} + +} // namespace + +std::optional GoosePduCodec::encoded_size( + const GoosePdu& pdu) noexcept { + const auto content = content_size(pdu); + return content + ? asn1::BerSpanWriter::tlv_size(kGooseApplicationTag, *content) + : std::nullopt; +} + +wire::EncodeResult GoosePduCodec::encode_into( + const GoosePdu& pdu, + const std::span destination) noexcept { + const auto required = encoded_size(pdu); + if (!required) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + const auto content = content_size(pdu); + const auto all_data_size = mms::MmsDataSpanCodec::encoded_all_size(pdu.values); + std::array timestamp{}; + if (!content || !all_data_size || !pdu.timestamp.try_write_bytes(timestamp)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + + asn1::BerSpanWriter writer{destination.first(*required)}; + if (!writer.write_tlv_header( + asn1::BerClass::application, + true, + kGooseApplicationTag, + *content) || + !write_string_field(writer, 0, pdu.go_cb_ref) || + !write_unsigned_field(writer, 1, pdu.time_allowed_to_live_milliseconds) || + !write_string_field(writer, 2, pdu.data_set_reference) || + !write_string_field(writer, 3, pdu.go_id) || + !writer.write_tlv( + asn1::BerClass::context_specific, + false, + 4, + timestamp) || + !write_unsigned_field(writer, 5, pdu.state_number) || + !write_unsigned_field(writer, 6, pdu.sequence_number) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 7, 1U) || + !writer.write_byte(pdu.test ? 0x01U : 0x00U) || + !write_unsigned_field(writer, 8, pdu.configuration_revision) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 9, 1U) || + !writer.write_byte(pdu.needs_commissioning ? 0x01U : 0x00U) || + !write_unsigned_field( + writer, + 10, + static_cast(pdu.values.size())) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, + true, + 11, + *all_data_size)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + + const auto all_data_result = mms::MmsDataSpanCodec::encode_all_into( + pdu.values, + destination.first(*required).subspan(writer.size(), *all_data_size)); + if (!all_data_result.success() || + all_data_result.bytes_written != *all_data_size) { + return {all_data_result.status, 0U, *required}; + } + + const auto written = writer.size() + all_data_result.bytes_written; + if (written != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, written, *required}; +} + +} // namespace ar::iec61850::goose diff --git a/src/mms/buffered_selective_information_report_span.cpp b/src/mms/buffered_selective_information_report_span.cpp new file mode 100644 index 000000000..621a5c34f --- /dev/null +++ b/src/mms/buffered_selective_information_report_span.cpp @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/buffered_selective_information_report_span.hpp" +#include "ariec61850/mms/buffered_selective_report_detail.hpp" + +#include "ariec61850/asn1/ber_span_writer.hpp" +#include "ariec61850/mms/pdu_span.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] bool validate_report( + const MmsBufferedSelectiveInformationReportSnapshotInput& report) noexcept { + using namespace detail; + if (!visible_ascii(report.report_id, MmsInformationReportSpanCodec::maximum_report_id_bytes) || + report.optional_fields.size() != MmsInformationReportSpanCodec::optional_field_bytes || + (report.optional_fields[0] & 0x80U) != 0U || + (report.optional_fields[1] & 0x7FU) != 0U || + report.data_set_member_count == 0U || + report.data_set_member_count > MmsInformationReportSpanCodec::maximum_members || + report.included_member_indices.empty() || + report.included_member_indices.size() > report.data_set_member_count || + report.included_member_references.size() != report.included_member_indices.size() || + report.included_member_results.size() != report.included_member_indices.size() || + !reference_content_size(report.data_set_reference)) { + return false; + } + if (optional_selected(report, 0U, opt_report_timestamp) && + report.report_time.size() != MmsInformationReportSpanCodec::binary_time_bytes) { + return false; + } + if (optional_selected(report, 0U, opt_entry_id)) { + if (report.entry_id.size() != MmsInformationReportSpanCodec::entry_id_bytes) return false; + } else if (!report.entry_id.empty()) { + return false; + } + if (optional_selected(report, 0U, opt_reason_for_inclusion)) { + if (report.included_reason_for_inclusion.size() != + report.included_member_indices.size()) return false; + for (const auto reason : report.included_reason_for_inclusion) { + if (reason == 0U || (reason & 0x03U) != 0U) return false; + } + } else if (!report.included_reason_for_inclusion.empty()) { + return false; + } + + std::size_t previous = 0U; + bool first = true; + for (const auto index : report.included_member_indices) { + if (index >= report.data_set_member_count || (!first && index <= previous)) return false; + previous = index; + first = false; + } + for (const auto& reference : report.included_member_references) { + if (!reference_content_size(reference)) return false; + } + for (const auto& result : report.included_member_results) { + if (!access_result_size(result)) return false; + } + return true; +} + +[[nodiscard]] std::optional access_content_size( + const MmsBufferedSelectiveInformationReportSnapshotInput& report) noexcept { + using namespace detail; + std::size_t total = 0U; + if (!add_to(total, asn1::BerSpanWriter::tlv_size(10, report.report_id.size())) || + !add_to(total, asn1::BerSpanWriter::tlv_size(4, 1U + report.optional_fields.size()))) { + return std::nullopt; + } + if (optional_selected(report, 0U, opt_sequence_number) && + !add_to(total, asn1::BerSpanWriter::tlv_size( + 6, minimal_unsigned_size(report.sequence_number)))) return std::nullopt; + if (optional_selected(report, 0U, opt_report_timestamp) && + !add_to(total, asn1::BerSpanWriter::tlv_size(12, report.report_time.size()))) { + return std::nullopt; + } + if (optional_selected(report, 0U, opt_data_set_name)) { + const auto content = reference_content_size(report.data_set_reference); + if (!content || !add_to(total, asn1::BerSpanWriter::tlv_size(10, *content))) { + return std::nullopt; + } + } + if (optional_selected(report, 0U, opt_buffer_overflow) && + !add_to(total, asn1::BerSpanWriter::tlv_size(3, 1U))) return std::nullopt; + if (optional_selected(report, 0U, opt_entry_id) && + !add_to(total, asn1::BerSpanWriter::tlv_size(9, report.entry_id.size()))) { + return std::nullopt; + } + if (optional_selected(report, 1U, opt_conf_revision) && + !add_to(total, asn1::BerSpanWriter::tlv_size( + 6, minimal_unsigned_size(report.conf_revision)))) return std::nullopt; + + const auto inclusion_bytes = (report.data_set_member_count + 7U) / 8U; + if (!add_to(total, asn1::BerSpanWriter::tlv_size(4, 1U + inclusion_bytes))) { + return std::nullopt; + } + if (optional_selected(report, 0U, opt_data_reference)) { + for (const auto& reference : report.included_member_references) { + const auto content = reference_content_size(reference); + if (!content || !add_to(total, asn1::BerSpanWriter::tlv_size(10, *content))) { + return std::nullopt; + } + } + } + for (const auto& result : report.included_member_results) { + if (!add_to(total, access_result_size(result))) return std::nullopt; + } + if (optional_selected(report, 0U, opt_reason_for_inclusion)) { + const auto reason_tlv = asn1::BerSpanWriter::tlv_size(4, 2U); + for (std::size_t index = 0U; index < report.included_member_indices.size(); ++index) { + if (!add_to(total, reason_tlv)) return std::nullopt; + } + } + return total; +} + +[[nodiscard]] bool write_inclusion( + asn1::BerSpanWriter& writer, + const std::size_t member_count, + const std::span included) noexcept { + const auto byte_count = (member_count + 7U) / 8U; + const auto unused = static_cast((byte_count * 8U) - member_count); + if (!writer.write_tlv_header(asn1::BerClass::context_specific, false, 4, 1U + byte_count) || + !writer.write_byte(unused)) return false; + + std::size_t included_offset = 0U; + for (std::size_t byte_index = 0U; byte_index < byte_count; ++byte_index) { + std::uint8_t value = 0U; + while (included_offset < included.size() && included[included_offset] / 8U == byte_index) { + const auto bit = included[included_offset] % 8U; + value = static_cast(value | static_cast(0x80U >> bit)); + ++included_offset; + } + if (!writer.write_byte(value)) return false; + } + return included_offset == included.size(); +} + +} // namespace + +wire::EncodeResult MmsBufferedSelectiveInformationReportSpanCodec::encode_snapshot_into( + const MmsBufferedSelectiveInformationReportSnapshotInput& report, + const std::span destination) noexcept { + using namespace detail; + if (!validate_report(report)) return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + + const auto access_content = access_content_size(report); + const auto access_tlv = access_content ? asn1::BerSpanWriter::tlv_size(0, *access_content) : std::nullopt; + const auto vmd_name_tlv = asn1::BerSpanWriter::tlv_size(0, 3U); + const auto variable_access_tlv = vmd_name_tlv ? asn1::BerSpanWriter::tlv_size(1, *vmd_name_tlv) : std::nullopt; + std::optional information_content; + if (access_tlv && variable_access_tlv && + *access_tlv <= std::numeric_limits::max() - *variable_access_tlv) { + information_content = *variable_access_tlv + *access_tlv; + } + const auto service_tlv = information_content ? asn1::BerSpanWriter::tlv_size(0, *information_content) : std::nullopt; + const auto required = service_tlv ? asn1::BerSpanWriter::tlv_size(3, *service_tlv) : std::nullopt; + if (!access_content || !access_tlv || !vmd_name_tlv || !variable_access_tlv || + !information_content || !service_tlv || !required || + *required > MmsPduSpanCodec::maximum_pdu_bytes) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + asn1::BerSpanWriter writer{destination.first(*required)}; + if (!writer.write_tlv_header(asn1::BerClass::context_specific, true, 3, *service_tlv) || + !writer.write_tlv_header(asn1::BerClass::context_specific, true, 0, *information_content) || + !writer.write_tlv_header(asn1::BerClass::context_specific, true, 1, *vmd_name_tlv) || + !writer.write_tlv_header(asn1::BerClass::context_specific, false, 0, 3U) || + !writer.write_bytes(std::span{ + reinterpret_cast("RPT"), 3U}) || + !writer.write_tlv_header(asn1::BerClass::context_specific, true, 0, *access_content) || + !write_visible(writer, report.report_id) || + !write_bit_string(writer, 6U, report.optional_fields)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + + if (optional_selected(report, 0U, opt_sequence_number) && + !write_unsigned(writer, report.sequence_number)) return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + if (optional_selected(report, 0U, opt_report_timestamp) && + (!writer.write_tlv_header(asn1::BerClass::context_specific, false, 12, report.report_time.size()) || + !writer.write_bytes(report.report_time))) return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + if (optional_selected(report, 0U, opt_data_set_name) && !write_reference(writer, report.data_set_reference)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 0U, opt_buffer_overflow) && !write_boolean(writer, report.buffer_overflow)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 0U, opt_entry_id) && + (!writer.write_tlv_header(asn1::BerClass::context_specific, false, 9, report.entry_id.size()) || + !writer.write_bytes(report.entry_id))) return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + if (optional_selected(report, 1U, opt_conf_revision) && !write_unsigned(writer, report.conf_revision)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (!write_inclusion(writer, report.data_set_member_count, report.included_member_indices)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 0U, opt_data_reference)) { + for (const auto& reference : report.included_member_references) { + if (!write_reference(writer, reference)) return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + } + for (const auto& result : report.included_member_results) { + if (!write_access_result(writer, result)) return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 0U, opt_reason_for_inclusion)) { + for (const auto reason : report.included_reason_for_inclusion) { + if (!write_bit_string(writer, 2U, std::span{&reason, 1U})) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + } + } + if (writer.size() != *required) return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + return {wire::EncodeStatus::ok, *required, *required}; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/connection_runtime.cpp b/src/mms/connection_runtime.cpp new file mode 100644 index 000000000..88e18abfe --- /dev/null +++ b/src/mms/connection_runtime.cpp @@ -0,0 +1,408 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/connection_runtime.hpp" + +#include "ariec61850/acse/association_span.hpp" +#include "ariec61850/mms/pdu_span.hpp" +#include "ariec61850/mms/services_span.hpp" +#include "ariec61850/osi/cotp_span.hpp" +#include "ariec61850/osi/presentation_span.hpp" +#include "ariec61850/osi/session_span.hpp" +#include "ariec61850/osi/tpkt_span.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] MmsStaticConnectionResult make_result( + const MmsStaticConnectionStatus status, + const MmsStaticConnectionState state, + const std::size_t consumed = 0U, + const std::size_t written = 0U) noexcept { + MmsStaticConnectionResult result; + result.status = status; + result.state = state; + result.consumed_bytes = consumed; + result.bytes_written = written; + return result; +} + +[[nodiscard]] MmsStaticConnectionResult make_response_capacity( + const MmsStaticConnectionState state, + const std::size_t required) noexcept { + auto result = make_result( + MmsStaticConnectionStatus::response_buffer_too_small, state); + result.required_response_bytes = required; + return result; +} + +[[nodiscard]] MmsStaticConnectionResult make_workspace_capacity( + const MmsStaticConnectionState state, + const std::size_t required) noexcept { + auto result = make_result( + MmsStaticConnectionStatus::workspace_too_small, state); + result.required_workspace_bytes = required; + return result; +} + +[[nodiscard]] bool add_overhead( + const std::size_t base, + const std::size_t overhead, + std::size_t& total) noexcept { + if (base > std::numeric_limits::max() - overhead) { + total = 0U; + return false; + } + total = base + overhead; + return true; +} + +[[nodiscard]] MmsStaticConnectionResult wrap_cotp_data_response( + const std::span session_or_presentation, + const std::size_t consumed, + const MmsStaticConnectionState state, + const std::span response, + const std::span workspace) noexcept { + // COTP Data adds three bytes and TPKT adds four bytes. Preflight both + // caller-owned buffers so a capacity retry never advances connection state. + std::size_t required{}; + if (!add_overhead(session_or_presentation.size(), 7U, required)) { + return make_result(MmsStaticConnectionStatus::backend_failure, state); + } + if (response.size() < required) { + return make_response_capacity(state, required); + } + if (workspace.size() < required) { + return make_workspace_capacity(state, required); + } + + const auto cotp = osi::CotpSpanCodec::encode_data_into( + session_or_presentation, + response.first(required - osi::TpktSpanCodec::header_length)); + if (!cotp.success()) { + if (cotp.status == wire::EncodeStatus::buffer_too_small) { + return make_response_capacity( + state, cotp.required_bytes + osi::TpktSpanCodec::header_length); + } + return make_result(MmsStaticConnectionStatus::backend_failure, state); + } + + const auto tpkt = osi::TpktSpanCodec::encode_into( + response.first(cotp.bytes_written), workspace.first(required)); + if (!tpkt.success() || tpkt.bytes_written != required) { + if (tpkt.status == wire::EncodeStatus::buffer_too_small) { + return make_workspace_capacity(state, tpkt.required_bytes); + } + return make_result(MmsStaticConnectionStatus::backend_failure, state); + } + std::copy_n(workspace.begin(), tpkt.bytes_written, response.begin()); + return make_result( + MmsStaticConnectionStatus::response_ready, + state, + consumed, + tpkt.bytes_written); +} + +[[nodiscard]] MmsStaticConnectionResult application_rejected( + const MmsStaticConnectionState state, + const std::size_t consumed, + const MmsStaticDispatchResult& application) noexcept { + auto result = make_result( + MmsStaticConnectionStatus::application_rejected, state, consumed); + result.application_status = application.status; + result.application_service = application.service; + result.invoke_id = application.invoke_id; + return result; +} + +[[nodiscard]] bool write_outer_capacity( + const MmsConfirmedPduView& confirmed, + const MmsStaticApplicationDispatcher& dispatcher, + const std::uint32_t presentation_context_id, + std::size_t& required) noexcept { + required = 0U; + if (confirmed.service() != MmsWireConfirmedService::write) { + return false; + } + + MmsWriteRequestView write; + if (!MmsServiceSpanCodec::try_decode_write_request(confirmed, write) || + write.variable_count == 0U || + write.variable_count > dispatcher.policy().maximum_write_variables) { + return false; + } + + std::array worst{}; + for (std::size_t index = 0U; index < write.variable_count; ++index) { + worst[index] = MmsWriteAccessResultInput{ + false, + std::numeric_limits::max()}; + } + const auto probe = MmsServiceSpanCodec::encode_write_response_into( + confirmed.invoke_id, + std::span{worst}.first(write.variable_count), + {}); + if (probe.status != wire::EncodeStatus::buffer_too_small || + probe.required_bytes == 0U) { + return false; + } + const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size( + presentation_context_id, probe.required_bytes); + return fully_encoded && add_overhead(*fully_encoded, 11U, required); +} + +} // namespace + +MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( + const std::span tcp_bytes, + const std::span response, + const std::span workspace) noexcept { + if (state_ == MmsStaticConnectionState::closed) { + return make_result(MmsStaticConnectionStatus::closed, state_); + } + if (state_ == MmsStaticConnectionState::fault) { + return make_result(MmsStaticConnectionStatus::protocol_violation, state_); + } + + const auto peek = osi::TpktSpanCodec::peek_frame(tcp_bytes); + if (peek.status == osi::TpktPeekStatus::need_more) { + return make_result(MmsStaticConnectionStatus::need_more, state_); + } + if (peek.status != osi::TpktPeekStatus::ready || peek.frame_bytes == 0U) { + state_ = MmsStaticConnectionState::fault; + return make_result(MmsStaticConnectionStatus::malformed_transport, state_); + } + + const auto frame_bytes = tcp_bytes.first(peek.frame_bytes); + osi::TpktFrameView tpkt; + osi::CotpTpduView cotp; + if (!osi::TpktSpanCodec::try_decode_view(frame_bytes, tpkt) || + !osi::CotpSpanCodec::try_decode_view(tpkt.payload, cotp)) { + state_ = MmsStaticConnectionState::fault; + return make_result( + MmsStaticConnectionStatus::malformed_transport, state_, peek.frame_bytes); + } + + if (cotp.kind == osi::CotpWireKind::disconnect_request) { + state_ = MmsStaticConnectionState::closed; + return make_result( + MmsStaticConnectionStatus::closed, state_, peek.frame_bytes); + } + + if (state_ == MmsStaticConnectionState::awaiting_cotp_connect) { + if (cotp.kind != osi::CotpWireKind::connection_request) { + state_ = MmsStaticConnectionState::fault; + return make_result( + MmsStaticConnectionStatus::protocol_violation, + state_, + peek.frame_bytes); + } + + const auto cc = osi::CotpSpanCodec::encode_connection_confirm_from_request_into( + cotp, + policy_.cotp_source_reference, + workspace, + policy_.maximum_tpdu_size_code); + if (!cc.success()) { + if (cc.status == wire::EncodeStatus::buffer_too_small) { + return make_workspace_capacity(state_, cc.required_bytes); + } + state_ = MmsStaticConnectionState::fault; + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + + std::size_t required{}; + if (!add_overhead(cc.bytes_written, osi::TpktSpanCodec::header_length, required)) { + state_ = MmsStaticConnectionState::fault; + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + if (response.size() < required) { + return make_response_capacity(state_, required); + } + const auto encoded = osi::TpktSpanCodec::encode_into( + workspace.first(cc.bytes_written), response.first(required)); + if (!encoded.success()) { + if (encoded.status == wire::EncodeStatus::buffer_too_small) { + return make_response_capacity(state_, encoded.required_bytes); + } + state_ = MmsStaticConnectionState::fault; + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + + state_ = MmsStaticConnectionState::awaiting_association; + return make_result( + MmsStaticConnectionStatus::response_ready, + state_, + peek.frame_bytes, + encoded.bytes_written); + } + + if (cotp.kind != osi::CotpWireKind::data || + (policy_.require_end_of_transmission && !cotp.end_of_transmission)) { + state_ = MmsStaticConnectionState::fault; + return make_result( + MmsStaticConnectionStatus::protocol_violation, + state_, + peek.frame_bytes); + } + + if (state_ == MmsStaticConnectionState::awaiting_association) { + acse::AssociationRequestView association; + if (!acse::AcseSpanCodec::try_decode_association_request_view( + cotp.user_data, association)) { + state_ = MmsStaticConnectionState::fault; + return make_result( + MmsStaticConnectionStatus::protocol_violation, + state_, + peek.frame_bytes); + } + + MmsInitiateView initiate; + if (!MmsPduSpanCodec::try_decode_initiate_request_view( + association.aarq.user_information.single_asn1_type, initiate) || + association.mms_presentation_context_id == 0U) { + state_ = MmsStaticConnectionState::fault; + return make_result( + MmsStaticConnectionStatus::protocol_violation, + state_, + peek.frame_bytes); + } + + const auto accept = acse::AcseSpanCodec::build_accept_response_into( + association, workspace); + if (!accept.success()) { + if (accept.status == wire::EncodeStatus::buffer_too_small) { + return make_workspace_capacity(state_, accept.required_bytes); + } + state_ = MmsStaticConnectionState::fault; + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + + const auto wrapped = wrap_cotp_data_response( + workspace.first(accept.bytes_written), + peek.frame_bytes, + MmsStaticConnectionState::established, + response, + workspace); + if (!wrapped.response_ready()) { + return wrapped; + } + mms_presentation_context_id_ = association.mms_presentation_context_id; + state_ = MmsStaticConnectionState::established; + auto result = wrapped; + result.state = state_; + return result; + } + + if (state_ != MmsStaticConnectionState::established) { + state_ = MmsStaticConnectionState::fault; + return make_result( + MmsStaticConnectionStatus::protocol_violation, + state_, + peek.frame_bytes); + } + + osi::SessionDataTransferView session; + osi::PresentationPdvView pdv; + if (!osi::SessionSpanCodec::try_decode_data_transfer_view(cotp.user_data, session) || + !osi::PresentationSpanCodec::try_decode_fully_encoded_data_view( + session.presentation_payload, pdv) || + pdv.context_id != mms_presentation_context_id_) { + state_ = MmsStaticConnectionState::fault; + return make_result( + MmsStaticConnectionStatus::protocol_violation, + state_, + peek.frame_bytes); + } + + MmsConfirmedPduView confirmed; + if (MmsPduSpanCodec::try_decode_confirmed_request_view( + pdv.single_asn1_type, confirmed)) { + std::size_t write_required{}; + if (write_outer_capacity( + confirmed, dispatcher_, mms_presentation_context_id_, write_required)) { + if (response.size() < write_required) { + return make_response_capacity(state_, write_required); + } + if (workspace.size() < write_required) { + return make_workspace_capacity(state_, write_required); + } + } + } + + const auto application = dispatcher_.dispatch( + pdv.single_asn1_type, response, workspace); + if (!application.success()) { + if (application.status == MmsStaticDispatchStatus::response_buffer_too_small) { + const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size( + mms_presentation_context_id_, application.required_bytes); + std::size_t required{}; + if (fully_encoded && add_overhead(*fully_encoded, 11U, required)) { + auto result = make_response_capacity(state_, required); + result.application_status = application.status; + result.application_service = application.service; + result.invoke_id = application.invoke_id; + return result; + } + state_ = MmsStaticConnectionState::fault; + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + if (application.status == MmsStaticDispatchStatus::workspace_too_small) { + auto result = make_workspace_capacity(state_, application.required_bytes); + result.application_status = application.status; + result.application_service = application.service; + result.invoke_id = application.invoke_id; + return result; + } + if (application.status == MmsStaticDispatchStatus::backend_failure || + application.status == MmsStaticDispatchStatus::invalid_object_table) { + state_ = MmsStaticConnectionState::fault; + auto result = make_result( + MmsStaticConnectionStatus::backend_failure, + state_, + peek.frame_bytes); + result.application_status = application.status; + result.application_service = application.service; + result.invoke_id = application.invoke_id; + return result; + } + return application_rejected(state_, peek.frame_bytes, application); + } + + const auto p_data = osi::PresentationSpanCodec::encode_p_data_into( + response.first(application.bytes_written), + workspace, + mms_presentation_context_id_, + true); + if (!p_data.success()) { + if (p_data.status == wire::EncodeStatus::buffer_too_small) { + std::size_t required{}; + if (add_overhead(p_data.required_bytes, 7U, required)) { + return make_workspace_capacity(state_, required); + } + } + state_ = MmsStaticConnectionState::fault; + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + + auto wrapped = wrap_cotp_data_response( + workspace.first(p_data.bytes_written), + peek.frame_bytes, + state_, + response, + workspace); + if (wrapped.response_ready()) { + wrapped.application_status = application.status; + wrapped.application_service = application.service; + wrapped.invoke_id = application.invoke_id; + } + return wrapped; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/data_set_span.cpp b/src/mms/data_set_span.cpp new file mode 100644 index 000000000..4deb74bc9 --- /dev/null +++ b/src/mms/data_set_span.cpp @@ -0,0 +1,355 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/data_set_span.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/asn1/ber_span_writer.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] bool valid_identifier(const std::string_view value) noexcept { + if (value.empty() || value.size() > MmsServiceSpanCodec::maximum_identifier_bytes) { + return false; + } + for (const char ch : value) { + const auto byte = static_cast(ch); + if (byte == 0U || byte > 0x7FU) { + return false; + } + } + return true; +} + +[[nodiscard]] std::size_t minimal_unsigned_size(std::uint32_t value) noexcept { + std::size_t bytes = 1U; + while (value > 0xFFU) { + ++bytes; + value >>= 8U; + } + return bytes; +} + +[[nodiscard]] bool needs_positive_prefix( + const std::uint32_t value, + const std::size_t minimal_bytes) noexcept { + const auto shift = static_cast((minimal_bytes - 1U) * 8U); + return ((value >> shift) & 0x80U) != 0U; +} + +[[nodiscard]] std::size_t positive_integer_size(const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + return minimal + (needs_positive_prefix(value, minimal) ? 1U : 0U); +} + +[[nodiscard]] bool write_positive_integer( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + if (needs_positive_prefix(value, minimal) && !writer.write_byte(0x00U)) { + return false; + } + for (std::size_t index = minimal; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte(static_cast((value >> shift) & 0xFFU))) { + return false; + } + } + return true; +} + +[[nodiscard]] std::optional checked_add( + const std::optional left, + const std::optional right) noexcept { + if (!left || !right || *right > std::numeric_limits::max() - *left) { + return std::nullopt; + } + return *left + *right; +} + +[[nodiscard]] std::optional member_encoded_size( + const MmsNamedVariableListMemberInput& member) noexcept { + if (!valid_identifier(member.domain) || !valid_identifier(member.item)) { + return std::nullopt; + } + const auto domain = asn1::BerSpanWriter::tlv_size(26, member.domain.size()); + const auto item = asn1::BerSpanWriter::tlv_size(26, member.item.size()); + const auto object_content = checked_add(domain, item); + if (!object_content) { + return std::nullopt; + } + const auto object_name = asn1::BerSpanWriter::tlv_size(1, *object_content); + if (!object_name) { + return std::nullopt; + } + const auto specification = asn1::BerSpanWriter::tlv_size(0, *object_name); + if (!specification) { + return std::nullopt; + } + return asn1::BerSpanWriter::tlv_size(16, *specification); +} + +struct ResponseSizing final { + std::size_t invoke_value{}; + std::size_t list_content{}; + std::size_t service_content{}; + std::size_t outer_content{}; + std::size_t required{}; +}; + +[[nodiscard]] std::optional response_sizing( + const std::uint32_t invoke_id, + const std::span members) noexcept { + if (invoke_id > MmsPduSpanCodec::maximum_invoke_id || + members.size() > MmsDataSetSpanCodec::maximum_members) { + return std::nullopt; + } + + std::size_t list_content = 0U; + for (const auto& member : members) { + const auto encoded = member_encoded_size(member); + if (!encoded || *encoded > std::numeric_limits::max() - list_content) { + return std::nullopt; + } + list_content += *encoded; + } + + const auto list_tlv = asn1::BerSpanWriter::tlv_size(1, list_content); + const auto deletable_tlv = asn1::BerSpanWriter::tlv_size(0, 1U); + const auto service_content = checked_add(deletable_tlv, list_tlv); + if (!list_tlv || !deletable_tlv || !service_content) { + return std::nullopt; + } + + const auto invoke_value = positive_integer_size(invoke_id); + const auto invoke_tlv = asn1::BerSpanWriter::tlv_size(2, invoke_value); + const auto service_tlv = asn1::BerSpanWriter::tlv_size(12, *service_content); + const auto outer_content = checked_add(invoke_tlv, service_tlv); + if (!invoke_tlv || !service_tlv || !outer_content) { + return std::nullopt; + } + const auto required = asn1::BerSpanWriter::tlv_size(1, *outer_content); + if (!required || *required > MmsPduSpanCodec::maximum_pdu_bytes) { + return std::nullopt; + } + + return ResponseSizing{ + invoke_value, + list_content, + *service_content, + *outer_content, + *required}; +} + +[[nodiscard]] bool decode_member_definition( + const asn1::BerTlvView& definition, + MmsObjectNameView& name) noexcept { + name = {}; + if (definition.tag_class != asn1::BerClass::universal || + definition.tag_number != 16 || !definition.constructed) { + return false; + } + asn1::BerTlvView specification; + if (!asn1::BerSpanReader::try_read_exact(definition.value, specification) || + specification.tag_class != asn1::BerClass::context_specific || + specification.tag_number != 0 || !specification.constructed) { + return false; + } + if (!MmsServiceSpanCodec::try_decode_object_name_view(specification.value, name)) { + return false; + } + return name.kind == MmsObjectNameViewKind::domain_specific && + !name.domain.empty() && !name.item.empty(); +} + +[[nodiscard]] bool validate_member_list( + const std::span bytes, + std::size_t& count) noexcept { + count = 0U; + std::size_t offset = 0U; + while (offset < bytes.size()) { + if (count >= MmsDataSetSpanCodec::maximum_members) { + count = 0U; + return false; + } + asn1::BerTlvView definition; + MmsObjectNameView name; + if (!asn1::BerSpanReader::try_read_tlv(bytes, offset, definition) || + !decode_member_definition(definition, name)) { + count = 0U; + return false; + } + ++count; + } + return true; +} + +} // namespace + +bool MmsNamedVariableListAttributesResponseView::try_member( + const std::size_t index, + MmsObjectNameView& name) const noexcept { + name = {}; + if (index >= member_count) { + return false; + } + std::size_t offset = 0U; + std::size_t current = 0U; + while (offset < variable_list.size()) { + asn1::BerTlvView definition; + if (!asn1::BerSpanReader::try_read_tlv(variable_list, offset, definition)) { + return false; + } + if (current == index) { + return decode_member_definition(definition, name); + } + ++current; + } + return false; +} + +bool MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_request( + const MmsConfirmedPduView& confirmed, + MmsNamedVariableListAttributesRequestView& request) noexcept { + request = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_request || + confirmed.service_tag != 12 || !confirmed.service_constructed) { + return false; + } + MmsObjectNameView name; + if (!MmsServiceSpanCodec::try_decode_object_name_view(confirmed.service_value, name) || + name.kind != MmsObjectNameViewKind::domain_specific) { + return false; + } + request.invoke_id = confirmed.invoke_id; + request.name = name; + return true; +} + +bool MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_request( + const std::span mms_pdu, + MmsNamedVariableListAttributesRequestView& request) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_request_view(mms_pdu, confirmed) && + try_decode_get_named_variable_list_attributes_request(confirmed, request); +} + +bool MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_response( + const MmsConfirmedPduView& confirmed, + MmsNamedVariableListAttributesResponseView& response) noexcept { + response = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_response || + confirmed.service_tag != 12 || !confirmed.service_constructed) { + return false; + } + + std::size_t offset = 0U; + asn1::BerTlvView deletable; + asn1::BerTlvView list; + if (!asn1::BerSpanReader::try_read_tlv(confirmed.service_value, offset, deletable) || + !asn1::BerSpanReader::try_read_tlv(confirmed.service_value, offset, list) || + offset != confirmed.service_value.size() || + deletable.tag_class != asn1::BerClass::context_specific || + deletable.tag_number != 0 || deletable.constructed || deletable.value.size() != 1U || + list.tag_class != asn1::BerClass::context_specific || + list.tag_number != 1 || !list.constructed) { + return false; + } + + std::size_t member_count = 0U; + if (!validate_member_list(list.value, member_count)) { + return false; + } + response.invoke_id = confirmed.invoke_id; + response.mms_deletable = deletable.value.front() != 0U; + response.variable_list = list.value; + response.member_count = member_count; + return true; +} + +bool MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_response( + const std::span mms_pdu, + MmsNamedVariableListAttributesResponseView& response) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_response_view(mms_pdu, confirmed) && + try_decode_get_named_variable_list_attributes_response(confirmed, response); +} + +wire::EncodeResult MmsDataSetSpanCodec::encode_get_named_variable_list_attributes_response_into( + const std::uint32_t invoke_id, + const bool mms_deletable, + const std::span members, + const std::span destination) noexcept { + const auto sizing = response_sizing(invoke_id, members); + if (!sizing) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < sizing->required) { + return {wire::EncodeStatus::buffer_too_small, 0U, sizing->required}; + } + + asn1::BerSpanWriter writer{destination.first(sizing->required)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, sizing->outer_content) || + !writer.write_tlv_header( + asn1::BerClass::universal, false, 2, sizing->invoke_value) || + !write_positive_integer(writer, invoke_id) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 12, sizing->service_content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, 1U) || + !writer.write_byte(mms_deletable ? 0xFFU : 0x00U) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, sizing->list_content)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + + for (const auto& member : members) { + const auto domain_tlv = asn1::BerSpanWriter::tlv_size(26, member.domain.size()); + const auto item_tlv = asn1::BerSpanWriter::tlv_size(26, member.item.size()); + const auto object_content = checked_add(domain_tlv, item_tlv); + const auto object_name = object_content + ? asn1::BerSpanWriter::tlv_size(1, *object_content) + : std::nullopt; + const auto specification = object_name + ? asn1::BerSpanWriter::tlv_size(0, *object_name) + : std::nullopt; + if (!domain_tlv || !item_tlv || !object_content || !object_name || !specification || + !writer.write_tlv_header( + asn1::BerClass::universal, true, 16, *specification) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, *object_name) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, *object_content) || + !writer.write_tlv( + asn1::BerClass::universal, + false, + 26, + std::span{ + reinterpret_cast(member.domain.data()), + member.domain.size()}) || + !writer.write_tlv( + asn1::BerClass::universal, + false, + 26, + std::span{ + reinterpret_cast(member.item.data()), + member.item.size()})) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + } + + if (writer.size() != sizing->required) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + return {wire::EncodeStatus::ok, sizing->required, sizing->required}; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/information_report_span.cpp b/src/mms/information_report_span.cpp new file mode 100644 index 000000000..85afbf644 --- /dev/null +++ b/src/mms/information_report_span.cpp @@ -0,0 +1,606 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/information_report_span.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/asn1/ber_span_writer.hpp" +#include "ariec61850/mms/pdu_span.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +constexpr std::uint8_t kOptSequenceNumber = 0x40U; +constexpr std::uint8_t kOptReportTimeStamp = 0x20U; +constexpr std::uint8_t kOptReasonForInclusion = 0x10U; +constexpr std::uint8_t kOptDataSetName = 0x08U; +constexpr std::uint8_t kOptDataReference = 0x04U; +constexpr std::uint8_t kOptBufferOverflow = 0x02U; +constexpr std::uint8_t kOptEntryId = 0x01U; +constexpr std::uint8_t kOptConfRevision = 0x80U; +constexpr std::uint8_t kOptSegmentation = 0x40U; + +[[nodiscard]] bool visible_ascii( + const std::string_view text, + const std::size_t maximum_bytes) noexcept { + if (text.empty() || text.size() > maximum_bytes) { + return false; + } + for (const auto character : text) { + const auto byte = static_cast(character); + if (byte == 0U || byte > 0x7FU) { + return false; + } + } + return true; +} + +[[nodiscard]] bool span_equals( + const std::span bytes, + const std::string_view text) noexcept { + if (bytes.size() != text.size()) { + return false; + } + for (std::size_t index = 0U; index < bytes.size(); ++index) { + if (bytes[index] != static_cast( + static_cast(text[index]))) { + return false; + } + } + return true; +} + +[[nodiscard]] std::optional add_size( + const std::optional left, + const std::optional right) noexcept { + if (!left || !right || *right > std::numeric_limits::max() - *left) { + return std::nullopt; + } + return *left + *right; +} + +[[nodiscard]] bool add_to( + std::size_t& total, + const std::optional value) noexcept { + if (!value || *value > std::numeric_limits::max() - total) { + return false; + } + total += *value; + return true; +} + +[[nodiscard]] std::size_t minimal_unsigned_size(std::uint32_t value) noexcept { + std::size_t bytes = 1U; + while (value > 0xFFU) { + ++bytes; + value >>= 8U; + } + return bytes; +} + +[[nodiscard]] bool needs_positive_prefix( + const std::uint32_t value, + const std::size_t minimal_bytes) noexcept { + const auto shift = static_cast((minimal_bytes - 1U) * 8U); + return ((value >> shift) & 0x80U) != 0U; +} + +[[nodiscard]] std::size_t positive_integer_size(const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + return minimal + (needs_positive_prefix(value, minimal) ? 1U : 0U); +} + +[[nodiscard]] bool write_unsigned_content( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto bytes = minimal_unsigned_size(value); + for (std::size_t index = bytes; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte(static_cast((value >> shift) & 0xFFU))) { + return false; + } + } + return true; +} + +[[nodiscard]] bool write_positive_content( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto bytes = minimal_unsigned_size(value); + if (needs_positive_prefix(value, bytes) && !writer.write_byte(0x00U)) { + return false; + } + return write_unsigned_content(writer, value); +} + +[[nodiscard]] bool valid_mms_data_tlv(const asn1::BerTlvView& tlv) noexcept { + if (tlv.tag_class != asn1::BerClass::context_specific) { + return false; + } + if (tlv.tag_number == 1 || tlv.tag_number == 2) { + return tlv.constructed; + } + return tlv.tag_number >= 3 && tlv.tag_number <= 17 && !tlv.constructed; +} + +[[nodiscard]] bool decode_access_result( + const std::span encoded, + MmsReadAccessResultView& result) noexcept { + result = {}; + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(encoded, tlv)) { + return false; + } + if (tlv.tag_class == asn1::BerClass::context_specific && + tlv.tag_number == 0 && !tlv.constructed) { + const auto failure = asn1::BerSpanReader::read_uint32(tlv); + if (!failure) { + return false; + } + result.success = false; + result.failure_code = *failure; + return true; + } + if (!valid_mms_data_tlv(tlv)) { + return false; + } + result.success = true; + result.encoded_data = encoded; + return true; +} + +[[nodiscard]] std::optional access_result_size( + const MmsReadAccessResultInput& result) noexcept { + if (result.success) { + asn1::BerTlvView data; + if (!asn1::BerSpanReader::try_read_exact(result.encoded_data, data) || + !valid_mms_data_tlv(data)) { + return std::nullopt; + } + return result.encoded_data.size(); + } + return asn1::BerSpanWriter::tlv_size(0, positive_integer_size(result.failure_code)); +} + +[[nodiscard]] bool write_access_result( + asn1::BerSpanWriter& writer, + const MmsReadAccessResultInput& result) noexcept { + if (result.success) { + return writer.write_bytes(result.encoded_data); + } + const auto content = positive_integer_size(result.failure_code); + return writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, content) && + write_positive_content(writer, result.failure_code); +} + +[[nodiscard]] std::optional reference_content_size( + const MmsInformationReportReferenceInput& reference) noexcept { + if (!visible_ascii(reference.domain, MmsServiceSpanCodec::maximum_identifier_bytes) || + !visible_ascii(reference.item, MmsServiceSpanCodec::maximum_identifier_bytes)) { + return std::nullopt; + } + if (reference.item.size() > + MmsInformationReportSpanCodec::maximum_reference_bytes - 1U || + reference.domain.size() > + MmsInformationReportSpanCodec::maximum_reference_bytes - 1U - reference.item.size()) { + return std::nullopt; + } + return reference.domain.size() + 1U + reference.item.size(); +} + +[[nodiscard]] bool write_reference_data( + asn1::BerSpanWriter& writer, + const MmsInformationReportReferenceInput& reference) noexcept { + const auto content = reference_content_size(reference); + if (!content || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 10, *content) || + !writer.write_bytes(std::span{ + reinterpret_cast(reference.domain.data()), + reference.domain.size()}) || + !writer.write_byte(static_cast('/')) || + !writer.write_bytes(std::span{ + reinterpret_cast(reference.item.data()), + reference.item.size()})) { + return false; + } + return true; +} + +[[nodiscard]] bool write_visible_data( + asn1::BerSpanWriter& writer, + const std::string_view text) noexcept { + return writer.write_tlv_header( + asn1::BerClass::context_specific, false, 10, text.size()) && + writer.write_bytes(std::span{ + reinterpret_cast(text.data()), text.size()}); +} + +[[nodiscard]] bool write_bit_string_data( + asn1::BerSpanWriter& writer, + const std::uint8_t unused_bits, + const std::span bytes) noexcept { + return unused_bits <= 7U && + writer.write_tlv_header( + asn1::BerClass::context_specific, false, 4, 1U + bytes.size()) && + writer.write_byte(unused_bits) && writer.write_bytes(bytes); +} + +[[nodiscard]] bool write_unsigned_data( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto content = minimal_unsigned_size(value); + return writer.write_tlv_header( + asn1::BerClass::context_specific, false, 6, content) && + write_unsigned_content(writer, value); +} + +[[nodiscard]] bool write_boolean_data( + asn1::BerSpanWriter& writer, + const bool value) noexcept { + return writer.write_tlv_header( + asn1::BerClass::context_specific, false, 3, 1U) && + writer.write_byte(value ? 0x01U : 0x00U); +} + +[[nodiscard]] bool optional_selected( + const MmsInformationReportSnapshotInput& report, + const std::size_t byte_index, + const std::uint8_t mask) noexcept { + return report.optional_fields.size() > byte_index && + (report.optional_fields[byte_index] & mask) != 0U; +} + +[[nodiscard]] bool validate_report( + const MmsInformationReportSnapshotInput& report) noexcept { + if (!visible_ascii( + report.report_id, + MmsInformationReportSpanCodec::maximum_report_id_bytes) || + report.optional_fields.size() != + MmsInformationReportSpanCodec::optional_field_bytes || + report.member_results.empty() || + report.member_results.size() > MmsInformationReportSpanCodec::maximum_members || + report.member_references.size() != report.member_results.size()) { + return false; + } + if ((report.optional_fields[0] & 0x80U) != 0U || + (report.optional_fields[1] & kOptSegmentation) != 0U || + (report.optional_fields[1] & 0x3FU) != 0U) { + return false; + } + if (!reference_content_size(report.data_set_reference)) { + return false; + } + for (const auto& reference : report.member_references) { + if (!reference_content_size(reference)) { + return false; + } + } + if (optional_selected(report, 0U, kOptReportTimeStamp) && + report.report_time.size() != MmsInformationReportSpanCodec::binary_time_bytes) { + return false; + } + if ((optional_selected(report, 0U, kOptBufferOverflow) || + optional_selected(report, 0U, kOptEntryId)) && !report.buffered) { + return false; + } + if (optional_selected(report, 0U, kOptEntryId) && + report.entry_id.size() != MmsInformationReportSpanCodec::entry_id_bytes) { + return false; + } + if (optional_selected(report, 0U, kOptReasonForInclusion) && + (report.reason_for_inclusion & 0x03U) != 0U) { + return false; + } + for (const auto& result : report.member_results) { + if (!access_result_size(result)) { + return false; + } + } + return true; +} + +[[nodiscard]] std::optional access_list_content_size( + const MmsInformationReportSnapshotInput& report) noexcept { + std::size_t total = 0U; + if (!add_to(total, asn1::BerSpanWriter::tlv_size(10, report.report_id.size())) || + !add_to(total, asn1::BerSpanWriter::tlv_size( + 4, 1U + report.optional_fields.size()))) { + return std::nullopt; + } + + if (optional_selected(report, 0U, kOptSequenceNumber) && + !add_to(total, asn1::BerSpanWriter::tlv_size( + 6, minimal_unsigned_size(report.sequence_number)))) { + return std::nullopt; + } + if (optional_selected(report, 0U, kOptReportTimeStamp) && + !add_to(total, asn1::BerSpanWriter::tlv_size(12, report.report_time.size()))) { + return std::nullopt; + } + if (optional_selected(report, 0U, kOptDataSetName)) { + const auto content = reference_content_size(report.data_set_reference); + if (!content || !add_to(total, asn1::BerSpanWriter::tlv_size(10, *content))) { + return std::nullopt; + } + } + if (optional_selected(report, 0U, kOptBufferOverflow) && + !add_to(total, asn1::BerSpanWriter::tlv_size(3, 1U))) { + return std::nullopt; + } + if (optional_selected(report, 0U, kOptEntryId) && + !add_to(total, asn1::BerSpanWriter::tlv_size(9, report.entry_id.size()))) { + return std::nullopt; + } + if (optional_selected(report, 1U, kOptConfRevision) && + !add_to(total, asn1::BerSpanWriter::tlv_size( + 6, minimal_unsigned_size(report.conf_revision)))) { + return std::nullopt; + } + + const auto inclusion_bytes = (report.member_results.size() + 7U) / 8U; + if (!add_to(total, asn1::BerSpanWriter::tlv_size(4, 1U + inclusion_bytes))) { + return std::nullopt; + } + + if (optional_selected(report, 0U, kOptDataReference)) { + for (const auto& reference : report.member_references) { + const auto content = reference_content_size(reference); + if (!content || !add_to(total, asn1::BerSpanWriter::tlv_size(10, *content))) { + return std::nullopt; + } + } + } + + for (const auto& result : report.member_results) { + if (!add_to(total, access_result_size(result))) { + return std::nullopt; + } + } + + if (optional_selected(report, 0U, kOptReasonForInclusion)) { + const auto reason_tlv = asn1::BerSpanWriter::tlv_size(4, 2U); + for (std::size_t index = 0U; index < report.member_results.size(); ++index) { + if (!add_to(total, reason_tlv)) { + return std::nullopt; + } + } + } + return total; +} + +[[nodiscard]] bool write_inclusion( + asn1::BerSpanWriter& writer, + const std::size_t member_count) noexcept { + const auto bytes = (member_count + 7U) / 8U; + const auto unused = static_cast((bytes * 8U) - member_count); + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 4, 1U + bytes) || + !writer.write_byte(unused)) { + return false; + } + for (std::size_t index = 0U; index < bytes; ++index) { + const auto first_member = index * 8U; + const auto remaining = member_count - first_member; + const auto used = remaining >= 8U ? 8U : remaining; + const auto value = used == 8U + ? std::uint8_t{0xFFU} + : static_cast(0xFFU << (8U - used)); + if (!writer.write_byte(value)) { + return false; + } + } + return true; +} + +} // namespace + +bool MmsInformationReportView::try_item( + const std::size_t index, + MmsReadAccessResultView& result) const noexcept { + result = {}; + if (index >= item_count) { + return false; + } + std::size_t offset = 0U; + std::size_t current = 0U; + while (offset < access_result_list.size()) { + const auto start = offset; + asn1::BerTlvView item; + if (!asn1::BerSpanReader::try_read_tlv(access_result_list, offset, item)) { + return false; + } + if (current == index) { + return decode_access_result( + access_result_list.subspan(start, offset - start), result); + } + ++current; + } + return false; +} + +bool MmsInformationReportSpanCodec::try_decode_information_report( + const std::span mms_pdu, + MmsInformationReportView& report) noexcept { + report = {}; + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(mms_pdu, outer) || + outer.tag_class != asn1::BerClass::context_specific || + outer.tag_number != 3 || !outer.constructed) { + return false; + } + + asn1::BerTlvView service; + if (!asn1::BerSpanReader::try_read_exact(outer.value, service) || + service.tag_class != asn1::BerClass::context_specific || + service.tag_number != 0 || !service.constructed) { + return false; + } + + std::size_t offset = 0U; + asn1::BerTlvView variable_access; + asn1::BerTlvView access_results; + if (!asn1::BerSpanReader::try_read_tlv(service.value, offset, variable_access) || + !asn1::BerSpanReader::try_read_tlv(service.value, offset, access_results) || + offset != service.value.size() || + variable_access.tag_class != asn1::BerClass::context_specific || + variable_access.tag_number != 1 || !variable_access.constructed || + access_results.tag_class != asn1::BerClass::context_specific || + access_results.tag_number != 0 || !access_results.constructed) { + return false; + } + + MmsObjectNameView list_name; + if (!MmsServiceSpanCodec::try_decode_object_name_view( + variable_access.value, list_name) || + list_name.kind != MmsObjectNameViewKind::vmd_specific || + !span_equals(list_name.item, "RPT")) { + return false; + } + + std::size_t result_offset = 0U; + std::size_t count = 0U; + while (result_offset < access_results.value.size()) { + if (count >= maximum_access_results) { + return false; + } + const auto start = result_offset; + asn1::BerTlvView item; + if (!asn1::BerSpanReader::try_read_tlv( + access_results.value, result_offset, item)) { + return false; + } + MmsReadAccessResultView decoded; + if (!decode_access_result( + access_results.value.subspan(start, result_offset - start), decoded)) { + return false; + } + ++count; + } + if (count == 0U) { + return false; + } + + report.variable_list_name = list_name; + report.access_result_list = access_results.value; + report.item_count = count; + return true; +} + +wire::EncodeResult MmsInformationReportSpanCodec::encode_snapshot_into( + const MmsInformationReportSnapshotInput& report, + const std::span destination) noexcept { + if (!validate_report(report)) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const auto access_content = access_list_content_size(report); + const auto access_tlv = access_content + ? asn1::BerSpanWriter::tlv_size(0, *access_content) + : std::nullopt; + const auto vmd_name_tlv = asn1::BerSpanWriter::tlv_size(0, 3U); + const auto variable_access_tlv = vmd_name_tlv + ? asn1::BerSpanWriter::tlv_size(1, *vmd_name_tlv) + : std::nullopt; + const auto information_content = add_size(variable_access_tlv, access_tlv); + const auto service_tlv = information_content + ? asn1::BerSpanWriter::tlv_size(0, *information_content) + : std::nullopt; + const auto required = service_tlv + ? asn1::BerSpanWriter::tlv_size(3, *service_tlv) + : std::nullopt; + if (!access_content || !access_tlv || !vmd_name_tlv || + !variable_access_tlv || !information_content || !service_tlv || !required || + *required > MmsPduSpanCodec::maximum_pdu_bytes) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + asn1::BerSpanWriter writer{destination.first(*required)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, true, 3, *service_tlv) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, *information_content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, *vmd_name_tlv) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, 3U) || + !writer.write_bytes(std::span{ + reinterpret_cast("RPT"), 3U}) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, *access_content) || + !write_visible_data(writer, report.report_id) || + !write_bit_string_data(writer, 6U, report.optional_fields)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + + if (optional_selected(report, 0U, kOptSequenceNumber) && + !write_unsigned_data(writer, report.sequence_number)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 0U, kOptReportTimeStamp) && + (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 12, report.report_time.size()) || + !writer.write_bytes(report.report_time))) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 0U, kOptDataSetName) && + !write_reference_data(writer, report.data_set_reference)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 0U, kOptBufferOverflow) && + !write_boolean_data(writer, report.buffer_overflow)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 0U, kOptEntryId) && + (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 9, report.entry_id.size()) || + !writer.write_bytes(report.entry_id))) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 1U, kOptConfRevision) && + !write_unsigned_data(writer, report.conf_revision)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (!write_inclusion(writer, report.member_results.size())) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + + if (optional_selected(report, 0U, kOptDataReference)) { + for (const auto& reference : report.member_references) { + if (!write_reference_data(writer, reference)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + } + } + for (const auto& result : report.member_results) { + if (!write_access_result(writer, result)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + } + if (optional_selected(report, 0U, kOptReasonForInclusion)) { + const std::uint8_t reason = report.reason_for_inclusion; + for (std::size_t index = 0U; index < report.member_results.size(); ++index) { + if (!write_bit_string_data( + writer, 2U, std::span{&reason, 1U})) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + } + } + + if (writer.size() != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, *required, *required}; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/pdu_span.cpp b/src/mms/pdu_span.cpp new file mode 100644 index 000000000..e0236d03c --- /dev/null +++ b/src/mms/pdu_span.cpp @@ -0,0 +1,399 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/pdu_span.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/asn1/ber_span_writer.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +constexpr std::array kDefaultInitiateResponse{ + 0xA9U, 0x26U, + 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, + 0x81U, 0x01U, 0x0AU, + 0x82U, 0x01U, 0x0AU, + 0x83U, 0x01U, 0x05U, + 0xA4U, 0x16U, + 0x80U, 0x01U, 0x01U, + 0x81U, 0x03U, 0x05U, 0xF1U, 0x00U, + 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, + 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U}; + +[[nodiscard]] bool read_u32( + const asn1::BerTlvView& tlv, + std::uint32_t& value) noexcept { + const auto decoded = asn1::BerSpanReader::read_uint32(tlv); + if (!decoded) { + value = 0U; + return false; + } + value = *decoded; + return true; +} + +[[nodiscard]] bool decode_detail( + const asn1::BerTlvView& field, + MmsInitiateDetailView& detail) noexcept { + detail = {}; + if (field.tag_class != asn1::BerClass::context_specific || + field.tag_number != 4 || !field.constructed) { + return false; + } + + std::array seen{}; + std::size_t count = 0U; + std::size_t offset = 0U; + while (offset < field.value.size()) { + if (count >= 3U) { + detail = {}; + return false; + } + ++count; + asn1::BerTlvView child; + if (!asn1::BerSpanReader::try_read_tlv(field.value, offset, child) || + child.tag_class != asn1::BerClass::context_specific || + child.constructed || child.tag_number < 0 || child.tag_number > 2) { + detail = {}; + return false; + } + const auto index = static_cast(child.tag_number); + if (seen[index]) { + detail = {}; + return false; + } + seen[index] = true; + + if (index == 0U) { + if (!read_u32(child, detail.version_number)) { + detail = {}; + return false; + } + } else if (index == 1U) { + if (child.value.empty() || child.value.size() > + MmsPduSpanCodec::maximum_bit_string_bytes) { + detail = {}; + return false; + } + detail.parameter_support_options = child.value; + } else { + if (child.value.empty() || child.value.size() > + MmsPduSpanCodec::maximum_bit_string_bytes) { + detail = {}; + return false; + } + detail.services_supported_calling = child.value; + } + } + return count == 3U && seen[0] && seen[1] && seen[2]; +} + +[[nodiscard]] bool decode_initiate( + const std::span bytes, + const std::int32_t expected_tag, + const MmsWirePduKind kind, + MmsInitiateView& result) noexcept { + result = {}; + if (bytes.empty() || bytes.size() > MmsPduSpanCodec::maximum_pdu_bytes) { + return false; + } + + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(bytes, outer) || + outer.tag_class != asn1::BerClass::context_specific || + outer.tag_number != expected_tag || !outer.constructed) { + return false; + } + + std::array seen{}; + std::array values{}; + MmsInitiateDetailView detail; + std::size_t count = 0U; + std::size_t offset = 0U; + while (offset < outer.value.size()) { + if (count >= 5U) { + result = {}; + return false; + } + ++count; + asn1::BerTlvView child; + if (!asn1::BerSpanReader::try_read_tlv(outer.value, offset, child) || + child.tag_class != asn1::BerClass::context_specific || + child.tag_number < 0 || child.tag_number > 4) { + result = {}; + return false; + } + const auto index = static_cast(child.tag_number); + if (seen[index]) { + result = {}; + return false; + } + seen[index] = true; + + if (index < 4U) { + if (child.constructed || !read_u32(child, values[index])) { + result = {}; + return false; + } + } else if (!decode_detail(child, detail)) { + result = {}; + return false; + } + } + + if (count != 5U || + !std::all_of(seen.begin(), seen.end(), [](const bool value) { return value; }) || + values[0] < 64U || values[0] > MmsPduSpanCodec::maximum_pdu_bytes || + values[1] == 0U || values[2] == 0U || values[3] == 0U) { + result = {}; + return false; + } + + result.kind = kind; + result.maximum_mms_pdu_size = values[0]; + result.maximum_outstanding_calling = values[1]; + result.maximum_outstanding_called = values[2]; + result.data_structure_nesting_level = values[3]; + result.detail = detail; + return true; +} + +[[nodiscard]] bool decode_invoke( + const asn1::BerTlvView& tlv, + std::uint32_t& invoke_id) noexcept { + const bool universal_integer = + tlv.tag_class == asn1::BerClass::universal && + tlv.tag_number == 2 && !tlv.constructed; + const bool implicit_invoke = + tlv.tag_class == asn1::BerClass::context_specific && + tlv.tag_number == 0 && !tlv.constructed; + if ((!universal_integer && !implicit_invoke) || !read_u32(tlv, invoke_id) || + invoke_id > MmsPduSpanCodec::maximum_invoke_id) { + invoke_id = 0U; + return false; + } + return true; +} + +[[nodiscard]] bool decode_confirmed( + const std::span bytes, + const std::int32_t expected_outer_tag, + const MmsWirePduKind kind, + MmsConfirmedPduView& result) noexcept { + result = {}; + if (bytes.empty() || bytes.size() > MmsPduSpanCodec::maximum_pdu_bytes) { + return false; + } + + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(bytes, outer) || + outer.tag_class != asn1::BerClass::context_specific || + outer.tag_number != expected_outer_tag || !outer.constructed) { + return false; + } + + asn1::BerTlvView first; + asn1::BerTlvView last; + std::size_t count = 0U; + std::size_t offset = 0U; + while (offset < outer.value.size()) { + if (count >= 4U) { + result = {}; + return false; + } + asn1::BerTlvView child; + if (!asn1::BerSpanReader::try_read_tlv(outer.value, offset, child)) { + result = {}; + return false; + } + if (count == 0U) { + first = child; + } + last = child; + ++count; + } + + if (count < 2U || !decode_invoke(first, result.invoke_id) || + last.tag_class != asn1::BerClass::context_specific || + last.tag_number < 0) { + result = {}; + return false; + } + + result.kind = kind; + result.service_tag = last.tag_number; + result.service_constructed = last.constructed; + result.service_value = last.value; + return true; +} + +[[nodiscard]] std::size_t minimal_unsigned_size(std::uint32_t value) noexcept { + std::size_t bytes = 1U; + while (value > 0xFFU) { + ++bytes; + value >>= 8U; + } + return bytes; +} + +[[nodiscard]] bool needs_positive_prefix( + const std::uint32_t value, + const std::size_t minimal_bytes) noexcept { + const auto shift = static_cast((minimal_bytes - 1U) * 8U); + return ((value >> shift) & 0x80U) != 0U; +} + +[[nodiscard]] std::size_t positive_integer_size(const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + return minimal + (needs_positive_prefix(value, minimal) ? 1U : 0U); +} + +[[nodiscard]] bool write_positive_integer( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + if (needs_positive_prefix(value, minimal) && !writer.write_byte(0x00U)) { + return false; + } + for (std::size_t index = minimal; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte(static_cast( + (value >> shift) & 0xFFU))) { + return false; + } + } + return true; +} + +[[nodiscard]] wire::EncodeResult encode_confirmed( + const std::int32_t outer_tag, + const std::uint32_t invoke_id, + const std::int32_t service_tag, + const bool service_constructed, + const std::span service_value, + const std::span destination) noexcept { + if (invoke_id > MmsPduSpanCodec::maximum_invoke_id || + service_tag < 0 || service_tag > 1'000'000 || + service_value.size() > MmsPduSpanCodec::maximum_pdu_bytes) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const auto invoke_value = positive_integer_size(invoke_id); + const auto invoke_tlv = asn1::BerSpanWriter::tlv_size(2, invoke_value); + const auto service_tlv = asn1::BerSpanWriter::tlv_size( + service_tag, service_value.size()); + if (!invoke_tlv || !service_tlv || + *service_tlv > std::numeric_limits::max() - *invoke_tlv) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto content = *invoke_tlv + *service_tlv; + const auto required = asn1::BerSpanWriter::tlv_size(outer_tag, content); + if (!required || *required > MmsPduSpanCodec::maximum_pdu_bytes) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + asn1::BerSpanWriter writer{destination.first(*required)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, true, outer_tag, content) || + !writer.write_tlv_header( + asn1::BerClass::universal, false, 2, invoke_value) || + !write_positive_integer(writer, invoke_id) || + !writer.write_tlv( + asn1::BerClass::context_specific, + service_constructed, + service_tag, + service_value) || + writer.size() != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, *required, *required}; +} + +} // namespace + +MmsWireConfirmedService MmsConfirmedPduView::service() const noexcept { + switch (service_tag) { + case 1: return MmsWireConfirmedService::get_name_list; + case 2: return MmsWireConfirmedService::identify; + case 4: return MmsWireConfirmedService::read; + case 5: return MmsWireConfirmedService::write; + case 6: return MmsWireConfirmedService::get_variable_access_attributes; + case 12: return MmsWireConfirmedService::get_named_variable_list_attributes; + default: return MmsWireConfirmedService::unknown; + } +} + +bool MmsPduSpanCodec::try_decode_initiate_request_view( + const std::span bytes, + MmsInitiateView& request) noexcept { + return decode_initiate(bytes, 8, MmsWirePduKind::initiate_request, request); +} + +bool MmsPduSpanCodec::try_decode_initiate_response_view( + const std::span bytes, + MmsInitiateView& response) noexcept { + return decode_initiate(bytes, 9, MmsWirePduKind::initiate_response, response); +} + +wire::EncodeResult MmsPduSpanCodec::encode_default_initiate_response_into( + const std::span destination) noexcept { + if (destination.size() < kDefaultInitiateResponse.size()) { + return { + wire::EncodeStatus::buffer_too_small, + 0U, + kDefaultInitiateResponse.size()}; + } + std::copy( + kDefaultInitiateResponse.begin(), + kDefaultInitiateResponse.end(), + destination.begin()); + return { + wire::EncodeStatus::ok, + kDefaultInitiateResponse.size(), + kDefaultInitiateResponse.size()}; +} + +bool MmsPduSpanCodec::try_decode_confirmed_request_view( + const std::span bytes, + MmsConfirmedPduView& request) noexcept { + return decode_confirmed(bytes, 0, MmsWirePduKind::confirmed_request, request); +} + +bool MmsPduSpanCodec::try_decode_confirmed_response_view( + const std::span bytes, + MmsConfirmedPduView& response) noexcept { + return decode_confirmed(bytes, 1, MmsWirePduKind::confirmed_response, response); +} + +wire::EncodeResult MmsPduSpanCodec::encode_confirmed_request_into( + const std::uint32_t invoke_id, + const std::int32_t service_tag, + const bool service_constructed, + const std::span service_value, + const std::span destination) noexcept { + return encode_confirmed( + 0, invoke_id, service_tag, service_constructed, service_value, destination); +} + +wire::EncodeResult MmsPduSpanCodec::encode_confirmed_response_into( + const std::uint32_t invoke_id, + const std::int32_t service_tag, + const bool service_constructed, + const std::span service_value, + const std::span destination) noexcept { + return encode_confirmed( + 1, invoke_id, service_tag, service_constructed, service_value, destination); +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/selective_information_report_span.cpp b/src/mms/selective_information_report_span.cpp new file mode 100644 index 000000000..97de3b51d --- /dev/null +++ b/src/mms/selective_information_report_span.cpp @@ -0,0 +1,446 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/selective_information_report_span.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/asn1/ber_span_writer.hpp" +#include "ariec61850/mms/pdu_span.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +constexpr std::uint8_t kOptSequenceNumber = 0x40U; +constexpr std::uint8_t kOptReportTimeStamp = 0x20U; +constexpr std::uint8_t kOptReasonForInclusion = 0x10U; +constexpr std::uint8_t kOptDataSetName = 0x08U; +constexpr std::uint8_t kOptDataReference = 0x04U; +constexpr std::uint8_t kOptConfRevision = 0x80U; + +[[nodiscard]] bool visible_ascii( + const std::string_view text, + const std::size_t maximum_bytes) noexcept { + if (text.empty() || text.size() > maximum_bytes) { + return false; + } + for (const auto character : text) { + const auto byte = static_cast(character); + if (byte == 0U || byte > 0x7FU) { + return false; + } + } + return true; +} + +[[nodiscard]] bool optional_selected( + const MmsSelectiveInformationReportSnapshotInput& report, + const std::size_t byte_index, + const std::uint8_t mask) noexcept { + return report.optional_fields.size() > byte_index && + (report.optional_fields[byte_index] & mask) != 0U; +} + +[[nodiscard]] std::size_t minimal_unsigned_size(std::uint32_t value) noexcept { + std::size_t bytes = 1U; + while (value > 0xFFU) { + ++bytes; + value >>= 8U; + } + return bytes; +} + +[[nodiscard]] bool needs_positive_prefix( + const std::uint32_t value, + const std::size_t minimal_bytes) noexcept { + const auto shift = static_cast((minimal_bytes - 1U) * 8U); + return ((value >> shift) & 0x80U) != 0U; +} + +[[nodiscard]] std::size_t positive_integer_size(const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + return minimal + (needs_positive_prefix(value, minimal) ? 1U : 0U); +} + +[[nodiscard]] bool write_unsigned_content( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto bytes = minimal_unsigned_size(value); + for (std::size_t index = bytes; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte(static_cast((value >> shift) & 0xFFU))) { + return false; + } + } + return true; +} + +[[nodiscard]] bool write_positive_content( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto bytes = minimal_unsigned_size(value); + if (needs_positive_prefix(value, bytes) && !writer.write_byte(0x00U)) { + return false; + } + return write_unsigned_content(writer, value); +} + +[[nodiscard]] bool valid_mms_data_tlv(const asn1::BerTlvView& tlv) noexcept { + if (tlv.tag_class != asn1::BerClass::context_specific) { + return false; + } + if (tlv.tag_number == 1 || tlv.tag_number == 2) { + return tlv.constructed; + } + return tlv.tag_number >= 3 && tlv.tag_number <= 17 && !tlv.constructed; +} + +[[nodiscard]] std::optional access_result_size( + const MmsReadAccessResultInput& result) noexcept { + if (result.success) { + asn1::BerTlvView data; + if (!asn1::BerSpanReader::try_read_exact(result.encoded_data, data) || + !valid_mms_data_tlv(data)) { + return std::nullopt; + } + return result.encoded_data.size(); + } + return asn1::BerSpanWriter::tlv_size( + 0, positive_integer_size(result.failure_code)); +} + +[[nodiscard]] bool write_access_result( + asn1::BerSpanWriter& writer, + const MmsReadAccessResultInput& result) noexcept { + if (result.success) { + return writer.write_bytes(result.encoded_data); + } + const auto content = positive_integer_size(result.failure_code); + return writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, content) && + write_positive_content(writer, result.failure_code); +} + +[[nodiscard]] std::optional reference_content_size( + const MmsInformationReportReferenceInput& reference) noexcept { + if (!visible_ascii(reference.domain, MmsServiceSpanCodec::maximum_identifier_bytes) || + !visible_ascii(reference.item, MmsServiceSpanCodec::maximum_identifier_bytes)) { + return std::nullopt; + } + if (reference.item.size() > MmsInformationReportSpanCodec::maximum_reference_bytes - 1U || + reference.domain.size() > + MmsInformationReportSpanCodec::maximum_reference_bytes - 1U - + reference.item.size()) { + return std::nullopt; + } + return reference.domain.size() + 1U + reference.item.size(); +} + +[[nodiscard]] bool write_reference_data( + asn1::BerSpanWriter& writer, + const MmsInformationReportReferenceInput& reference) noexcept { + const auto content = reference_content_size(reference); + if (!content || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 10, *content) || + !writer.write_bytes(std::span{ + reinterpret_cast(reference.domain.data()), + reference.domain.size()}) || + !writer.write_byte(static_cast('/')) || + !writer.write_bytes(std::span{ + reinterpret_cast(reference.item.data()), + reference.item.size()})) { + return false; + } + return true; +} + +[[nodiscard]] bool write_visible_data( + asn1::BerSpanWriter& writer, + const std::string_view text) noexcept { + return writer.write_tlv_header( + asn1::BerClass::context_specific, false, 10, text.size()) && + writer.write_bytes(std::span{ + reinterpret_cast(text.data()), text.size()}); +} + +[[nodiscard]] bool write_bit_string_data( + asn1::BerSpanWriter& writer, + const std::uint8_t unused_bits, + const std::span bytes) noexcept { + return unused_bits <= 7U && + writer.write_tlv_header( + asn1::BerClass::context_specific, false, 4, 1U + bytes.size()) && + writer.write_byte(unused_bits) && writer.write_bytes(bytes); +} + +[[nodiscard]] bool write_unsigned_data( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto content = minimal_unsigned_size(value); + return writer.write_tlv_header( + asn1::BerClass::context_specific, false, 6, content) && + write_unsigned_content(writer, value); +} + +[[nodiscard]] bool validate_report( + const MmsSelectiveInformationReportSnapshotInput& report) noexcept { + if (!visible_ascii( + report.report_id, + MmsInformationReportSpanCodec::maximum_report_id_bytes) || + report.optional_fields.size() != MmsInformationReportSpanCodec::optional_field_bytes || + (report.optional_fields[0] & 0x83U) != 0U || + (report.optional_fields[1] & 0x7FU) != 0U || + report.data_set_member_count == 0U || + report.data_set_member_count > MmsInformationReportSpanCodec::maximum_members || + report.included_member_indices.empty() || + report.included_member_indices.size() > report.data_set_member_count || + report.included_member_references.size() != report.included_member_indices.size() || + report.included_member_results.size() != report.included_member_indices.size() || + !reference_content_size(report.data_set_reference)) { + return false; + } + if (optional_selected(report, 0U, kOptReportTimeStamp) && + report.report_time.size() != MmsInformationReportSpanCodec::binary_time_bytes) { + return false; + } + if (optional_selected(report, 0U, kOptReasonForInclusion)) { + if (report.included_reason_for_inclusion.size() != + report.included_member_indices.size()) { + return false; + } + for (const auto reason : report.included_reason_for_inclusion) { + if (reason == 0U || (reason & 0x03U) != 0U) { + return false; + } + } + } else if (!report.included_reason_for_inclusion.empty()) { + return false; + } + + std::size_t previous = 0U; + bool first = true; + for (const auto index : report.included_member_indices) { + if (index >= report.data_set_member_count || (!first && index <= previous)) { + return false; + } + previous = index; + first = false; + } + for (const auto& reference : report.included_member_references) { + if (!reference_content_size(reference)) { + return false; + } + } + for (const auto& result : report.included_member_results) { + if (!access_result_size(result)) { + return false; + } + } + return true; +} + +[[nodiscard]] bool add_to( + std::size_t& total, + const std::optional value) noexcept { + if (!value || *value > std::numeric_limits::max() - total) { + return false; + } + total += *value; + return true; +} + +[[nodiscard]] std::optional access_content_size( + const MmsSelectiveInformationReportSnapshotInput& report) noexcept { + std::size_t total = 0U; + if (!add_to(total, asn1::BerSpanWriter::tlv_size(10, report.report_id.size())) || + !add_to(total, asn1::BerSpanWriter::tlv_size( + 4, 1U + report.optional_fields.size()))) { + return std::nullopt; + } + if (optional_selected(report, 0U, kOptSequenceNumber) && + !add_to(total, asn1::BerSpanWriter::tlv_size( + 6, minimal_unsigned_size(report.sequence_number)))) { + return std::nullopt; + } + if (optional_selected(report, 0U, kOptReportTimeStamp) && + !add_to(total, asn1::BerSpanWriter::tlv_size(12, report.report_time.size()))) { + return std::nullopt; + } + if (optional_selected(report, 0U, kOptDataSetName)) { + const auto content = reference_content_size(report.data_set_reference); + if (!content || !add_to(total, asn1::BerSpanWriter::tlv_size(10, *content))) { + return std::nullopt; + } + } + if (optional_selected(report, 1U, kOptConfRevision) && + !add_to(total, asn1::BerSpanWriter::tlv_size( + 6, minimal_unsigned_size(report.conf_revision)))) { + return std::nullopt; + } + + const auto inclusion_bytes = (report.data_set_member_count + 7U) / 8U; + if (!add_to(total, asn1::BerSpanWriter::tlv_size(4, 1U + inclusion_bytes))) { + return std::nullopt; + } + if (optional_selected(report, 0U, kOptDataReference)) { + for (const auto& reference : report.included_member_references) { + const auto content = reference_content_size(reference); + if (!content || !add_to(total, asn1::BerSpanWriter::tlv_size(10, *content))) { + return std::nullopt; + } + } + } + for (const auto& result : report.included_member_results) { + if (!add_to(total, access_result_size(result))) { + return std::nullopt; + } + } + if (optional_selected(report, 0U, kOptReasonForInclusion)) { + const auto reason_tlv = asn1::BerSpanWriter::tlv_size(4, 2U); + for (std::size_t index = 0U; index < report.included_member_indices.size(); ++index) { + if (!add_to(total, reason_tlv)) { + return std::nullopt; + } + } + } + return total; +} + +[[nodiscard]] bool write_inclusion( + asn1::BerSpanWriter& writer, + const std::size_t member_count, + const std::span included) noexcept { + const auto byte_count = (member_count + 7U) / 8U; + const auto unused = static_cast((byte_count * 8U) - member_count); + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 4, 1U + byte_count) || + !writer.write_byte(unused)) { + return false; + } + + std::size_t included_offset = 0U; + for (std::size_t byte_index = 0U; byte_index < byte_count; ++byte_index) { + std::uint8_t value = 0U; + while (included_offset < included.size() && + included[included_offset] / 8U == byte_index) { + const auto bit = included[included_offset] % 8U; + value = static_cast( + value | static_cast(0x80U >> bit)); + ++included_offset; + } + if (!writer.write_byte(value)) { + return false; + } + } + return included_offset == included.size(); +} + +} // namespace + +wire::EncodeResult MmsSelectiveInformationReportSpanCodec::encode_snapshot_into( + const MmsSelectiveInformationReportSnapshotInput& report, + const std::span destination) noexcept { + if (!validate_report(report)) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto access_content = access_content_size(report); + const auto access_tlv = access_content + ? asn1::BerSpanWriter::tlv_size(0, *access_content) + : std::nullopt; + const auto vmd_name_tlv = asn1::BerSpanWriter::tlv_size(0, 3U); + const auto variable_access_tlv = vmd_name_tlv + ? asn1::BerSpanWriter::tlv_size(1, *vmd_name_tlv) + : std::nullopt; + std::optional information_content; + if (access_tlv && variable_access_tlv && + *access_tlv <= std::numeric_limits::max() - *variable_access_tlv) { + information_content = *variable_access_tlv + *access_tlv; + } + const auto service_tlv = information_content + ? asn1::BerSpanWriter::tlv_size(0, *information_content) + : std::nullopt; + const auto required = service_tlv + ? asn1::BerSpanWriter::tlv_size(3, *service_tlv) + : std::nullopt; + if (!access_content || !access_tlv || !vmd_name_tlv || !variable_access_tlv || + !information_content || !service_tlv || !required || + *required > MmsPduSpanCodec::maximum_pdu_bytes) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + asn1::BerSpanWriter writer{destination.first(*required)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, true, 3, *service_tlv) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, *information_content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, *vmd_name_tlv) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, 3U) || + !writer.write_bytes(std::span{ + reinterpret_cast("RPT"), 3U}) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, *access_content) || + !write_visible_data(writer, report.report_id) || + !write_bit_string_data(writer, 6U, report.optional_fields)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + + if (optional_selected(report, 0U, kOptSequenceNumber) && + !write_unsigned_data(writer, report.sequence_number)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 0U, kOptReportTimeStamp) && + (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 12, report.report_time.size()) || + !writer.write_bytes(report.report_time))) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 0U, kOptDataSetName) && + !write_reference_data(writer, report.data_set_reference)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 1U, kOptConfRevision) && + !write_unsigned_data(writer, report.conf_revision)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (!write_inclusion( + writer, report.data_set_member_count, report.included_member_indices)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 0U, kOptDataReference)) { + for (const auto& reference : report.included_member_references) { + if (!write_reference_data(writer, reference)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + } + } + for (const auto& result : report.included_member_results) { + if (!write_access_result(writer, result)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + } + if (optional_selected(report, 0U, kOptReasonForInclusion)) { + for (const auto reason : report.included_reason_for_inclusion) { + if (!write_bit_string_data( + writer, 2U, std::span{&reason, 1U})) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + } + } + if (writer.size() != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, *required, *required}; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/services_span.cpp b/src/mms/services_span.cpp new file mode 100644 index 000000000..e68152280 --- /dev/null +++ b/src/mms/services_span.cpp @@ -0,0 +1,971 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/services_span.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/asn1/ber_span_writer.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] bool visible_ascii( + const std::span value, + const bool allow_empty = false) noexcept { + if ((!allow_empty && value.empty()) || + value.size() > MmsServiceSpanCodec::maximum_identifier_bytes) { + return false; + } + for (const auto byte : value) { + if (byte == 0U || byte > 0x7FU) { + return false; + } + } + return true; +} + +[[nodiscard]] bool visible_ascii( + const std::string_view value, + const bool allow_empty = false) noexcept { + if ((!allow_empty && value.empty()) || + value.size() > MmsServiceSpanCodec::maximum_identifier_bytes) { + return false; + } + for (const auto character : value) { + const auto byte = static_cast(character); + if (byte == 0U || byte > 0x7FU) { + return false; + } + } + return true; +} + +[[nodiscard]] bool read_u32( + const asn1::BerTlvView& tlv, + std::uint32_t& value) noexcept { + const auto decoded = asn1::BerSpanReader::read_uint32(tlv); + if (!decoded) { + value = 0U; + return false; + } + value = *decoded; + return true; +} + +[[nodiscard]] bool valid_identifier_tlv( + const asn1::BerTlvView& tlv) noexcept { + return !tlv.constructed && + tlv.tag_class == asn1::BerClass::universal && + (tlv.tag_number == 22 || tlv.tag_number == 26) && + visible_ascii(tlv.value); +} + +[[nodiscard]] bool valid_mms_data_tlv( + const asn1::BerTlvView& tlv) noexcept { + if (tlv.tag_class != asn1::BerClass::context_specific) { + return false; + } + if (tlv.tag_number == 1 || tlv.tag_number == 2) { + return tlv.constructed; + } + return tlv.tag_number >= 3 && tlv.tag_number <= 17 && !tlv.constructed; +} + +[[nodiscard]] std::size_t minimal_unsigned_size(std::uint32_t value) noexcept { + std::size_t bytes = 1U; + while (value > 0xFFU) { + ++bytes; + value >>= 8U; + } + return bytes; +} + +[[nodiscard]] bool needs_positive_prefix( + const std::uint32_t value, + const std::size_t minimal_bytes) noexcept { + const auto shift = static_cast((minimal_bytes - 1U) * 8U); + return ((value >> shift) & 0x80U) != 0U; +} + +[[nodiscard]] std::size_t positive_integer_size(const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + return minimal + (needs_positive_prefix(value, minimal) ? 1U : 0U); +} + +[[nodiscard]] bool write_positive_integer( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + if (needs_positive_prefix(value, minimal) && !writer.write_byte(0x00U)) { + return false; + } + for (std::size_t index = minimal; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte(static_cast( + (value >> shift) & 0xFFU))) { + return false; + } + } + return true; +} + +[[nodiscard]] std::optional add_size( + const std::optional left, + const std::optional right) noexcept { + if (!left || !right || *right > std::numeric_limits::max() - *left) { + return std::nullopt; + } + return *left + *right; +} + +struct ConfirmedResponseSizing final { + std::size_t invoke_value{}; + std::size_t outer_content{}; + std::size_t required{}; +}; + +[[nodiscard]] std::optional confirmed_response_sizing( + const std::uint32_t invoke_id, + const std::int32_t service_tag, + const std::size_t service_content) noexcept { + if (invoke_id > MmsPduSpanCodec::maximum_invoke_id || + service_tag < 0 || service_tag > 1'000'000) { + return std::nullopt; + } + const auto invoke_value = positive_integer_size(invoke_id); + const auto invoke_tlv = asn1::BerSpanWriter::tlv_size(2, invoke_value); + const auto service_tlv = asn1::BerSpanWriter::tlv_size(service_tag, service_content); + const auto outer_content = add_size(invoke_tlv, service_tlv); + if (!outer_content) { + return std::nullopt; + } + const auto required = asn1::BerSpanWriter::tlv_size(1, *outer_content); + if (!required || *required > MmsPduSpanCodec::maximum_pdu_bytes) { + return std::nullopt; + } + return ConfirmedResponseSizing{invoke_value, *outer_content, *required}; +} + +[[nodiscard]] bool write_confirmed_response_prefix( + asn1::BerSpanWriter& writer, + const ConfirmedResponseSizing& sizing, + const std::uint32_t invoke_id, + const std::int32_t service_tag, + const std::size_t service_content) noexcept { + return writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, sizing.outer_content) && + writer.write_tlv_header( + asn1::BerClass::universal, false, 2, sizing.invoke_value) && + write_positive_integer(writer, invoke_id) && + writer.write_tlv_header( + asn1::BerClass::context_specific, true, service_tag, service_content); +} + +[[nodiscard]] bool try_read_variable_definition( + const std::span variable_list, + const std::size_t wanted, + MmsObjectNameView& name) noexcept { + name = {}; + std::size_t offset = 0U; + std::size_t current = 0U; + while (offset < variable_list.size()) { + asn1::BerTlvView definition; + if (!asn1::BerSpanReader::try_read_tlv(variable_list, offset, definition) || + definition.tag_class != asn1::BerClass::universal || + definition.tag_number != 16 || !definition.constructed) { + return false; + } + asn1::BerTlvView wrapper; + if (!asn1::BerSpanReader::try_read_exact(definition.value, wrapper) || + wrapper.tag_class != asn1::BerClass::context_specific || + wrapper.tag_number != 0 || !wrapper.constructed) { + return false; + } + MmsObjectNameView decoded; + if (!MmsServiceSpanCodec::try_decode_object_name_view(wrapper.value, decoded)) { + return false; + } + if (current == wanted) { + name = decoded; + return true; + } + ++current; + } + return false; +} + +[[nodiscard]] bool try_read_access_result( + const std::span list, + const std::size_t wanted, + MmsReadAccessResultView& result) noexcept { + result = {}; + std::size_t offset = 0U; + std::size_t current = 0U; + while (offset < list.size()) { + const auto start = offset; + asn1::BerTlvView access; + if (!asn1::BerSpanReader::try_read_tlv(list, offset, access)) { + return false; + } + MmsReadAccessResultView decoded; + if (access.tag_class == asn1::BerClass::context_specific && + access.tag_number == 0 && !access.constructed) { + if (!read_u32(access, decoded.failure_code)) { + return false; + } + decoded.success = false; + } else if (valid_mms_data_tlv(access)) { + decoded.success = true; + decoded.encoded_data = list.subspan(start, offset - start); + } else { + return false; + } + if (current == wanted) { + result = decoded; + return true; + } + ++current; + } + return false; +} + +} // namespace + +bool MmsServiceSpanCodec::try_decode_object_name_view( + const std::span encoded_object_name, + MmsObjectNameView& name) noexcept { + name = {}; + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(encoded_object_name, outer) || + outer.tag_class != asn1::BerClass::context_specific) { + return false; + } + if (outer.tag_number == 0 && !outer.constructed) { + if (!visible_ascii(outer.value)) { + return false; + } + name.kind = MmsObjectNameViewKind::vmd_specific; + name.item = outer.value; + return true; + } + if (outer.tag_number == 2 && !outer.constructed) { + if (!visible_ascii(outer.value)) { + return false; + } + name.kind = MmsObjectNameViewKind::aa_specific; + name.item = outer.value; + return true; + } + if (outer.tag_number != 1 || !outer.constructed) { + return false; + } + + std::size_t offset = 0U; + asn1::BerTlvView domain; + asn1::BerTlvView item; + if (!asn1::BerSpanReader::try_read_tlv(outer.value, offset, domain) || + !asn1::BerSpanReader::try_read_tlv(outer.value, offset, item) || + offset != outer.value.size() || + !valid_identifier_tlv(domain) || !valid_identifier_tlv(item)) { + return false; + } + name.kind = MmsObjectNameViewKind::domain_specific; + name.domain = domain.value; + name.item = item.value; + return true; +} + +bool MmsGetNameListResponseView::try_identifier( + const std::size_t index, + std::span& identifier) const noexcept { + identifier = {}; + if (index >= identifier_count) { + return false; + } + std::size_t offset = 0U; + std::size_t current = 0U; + while (offset < identifier_list.size()) { + asn1::BerTlvView item; + if (!asn1::BerSpanReader::try_read_tlv(identifier_list, offset, item) || + !valid_identifier_tlv(item)) { + return false; + } + if (current == index) { + identifier = item.value; + return true; + } + ++current; + } + return false; +} + +bool MmsReadRequestView::try_variable( + const std::size_t index, + MmsObjectNameView& name) const noexcept { + if (index >= variable_count) { + name = {}; + return false; + } + return try_read_variable_definition(variable_list, index, name); +} + +bool MmsReadResponseView::try_result( + const std::size_t index, + MmsReadAccessResultView& result) const noexcept { + if (index >= result_count) { + result = {}; + return false; + } + return try_read_access_result(access_result_list, index, result); +} + +bool MmsServiceSpanCodec::try_decode_get_name_list_request( + const MmsConfirmedPduView& confirmed, + MmsGetNameListRequestView& request) noexcept { + request = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_request || + confirmed.service_tag != 1 || !confirmed.service_constructed) { + return false; + } + + bool have_class = false; + bool have_scope = false; + bool have_continuation = false; + std::size_t count = 0U; + std::size_t offset = 0U; + while (offset < confirmed.service_value.size()) { + if (count >= 3U) { + request = {}; + return false; + } + ++count; + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv( + confirmed.service_value, offset, field) || + field.tag_class != asn1::BerClass::context_specific) { + request = {}; + return false; + } + + if (field.tag_number == 0 && field.constructed) { + if (have_class) { + request = {}; + return false; + } + asn1::BerTlvView inner; + std::uint32_t value{}; + if (!asn1::BerSpanReader::try_read_exact(field.value, inner) || + inner.tag_class != asn1::BerClass::context_specific || + inner.tag_number != 0 || inner.constructed || + !read_u32(inner, value) || + (value != 0U && value != 2U && value != 9U)) { + request = {}; + return false; + } + request.object_class = static_cast(value); + have_class = true; + } else if (field.tag_number == 1 && field.constructed) { + if (have_scope) { + request = {}; + return false; + } + asn1::BerTlvView inner; + if (!asn1::BerSpanReader::try_read_exact(field.value, inner) || + inner.tag_class != asn1::BerClass::context_specific || + inner.constructed) { + request = {}; + return false; + } + switch (inner.tag_number) { + case 0: + if (!inner.value.empty()) { + request = {}; + return false; + } + request.scope = MmsNameScopeKind::vmd_specific; + break; + case 1: + if (!visible_ascii(inner.value)) { + request = {}; + return false; + } + request.scope = MmsNameScopeKind::domain_specific; + request.domain_id = inner.value; + break; + case 2: + if (!inner.value.empty()) { + request = {}; + return false; + } + request.scope = MmsNameScopeKind::aa_specific; + break; + default: + request = {}; + return false; + } + have_scope = true; + } else if (field.tag_number == 2 && !field.constructed) { + if (have_continuation || !visible_ascii(field.value)) { + request = {}; + return false; + } + request.continue_after = field.value; + have_continuation = true; + } else { + request = {}; + return false; + } + } + + if (!have_class || !have_scope) { + request = {}; + return false; + } + request.invoke_id = confirmed.invoke_id; + return true; +} + +bool MmsServiceSpanCodec::try_decode_get_name_list_request( + const std::span mms_pdu, + MmsGetNameListRequestView& request) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_request_view(mms_pdu, confirmed) && + try_decode_get_name_list_request(confirmed, request); +} + +bool MmsServiceSpanCodec::try_decode_get_name_list_response( + const MmsConfirmedPduView& confirmed, + MmsGetNameListResponseView& response) noexcept { + response = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_response || + confirmed.service_tag != 1 || !confirmed.service_constructed) { + return false; + } + + bool have_list = false; + bool have_more = false; + std::size_t offset = 0U; + while (offset < confirmed.service_value.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv( + confirmed.service_value, offset, field) || + field.tag_class != asn1::BerClass::context_specific) { + response = {}; + return false; + } + + if (field.tag_number == 0 && field.constructed) { + if (have_list) { + response = {}; + return false; + } + std::size_t name_offset = 0U; + std::size_t name_count = 0U; + while (name_offset < field.value.size()) { + if (name_count >= maximum_identifiers) { + response = {}; + return false; + } + asn1::BerTlvView name; + if (!asn1::BerSpanReader::try_read_tlv( + field.value, name_offset, name) || + !valid_identifier_tlv(name)) { + response = {}; + return false; + } + ++name_count; + } + response.identifier_list = field.value; + response.identifier_count = name_count; + have_list = true; + } else if (field.tag_number == 1 && !field.constructed) { + if (have_more || field.value.size() != 1U) { + response = {}; + return false; + } + response.more_follows = field.value[0] != 0U; + have_more = true; + } else { + response = {}; + return false; + } + } + + if (!have_list) { + response = {}; + return false; + } + response.invoke_id = confirmed.invoke_id; + return true; +} + +bool MmsServiceSpanCodec::try_decode_get_name_list_response( + const std::span mms_pdu, + MmsGetNameListResponseView& response) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_response_view(mms_pdu, confirmed) && + try_decode_get_name_list_response(confirmed, response); +} + +wire::EncodeResult MmsServiceSpanCodec::encode_get_name_list_response_into( + const std::uint32_t invoke_id, + const std::span names, + const bool more_follows, + const std::span destination) noexcept { + if (invoke_id > MmsPduSpanCodec::maximum_invoke_id || + names.size() > maximum_identifiers) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + std::size_t names_content = 0U; + for (const auto name : names) { + if (!visible_ascii(name)) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto encoded = asn1::BerSpanWriter::tlv_size(26, name.size()); + if (!encoded || *encoded > std::numeric_limits::max() - names_content) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + names_content += *encoded; + } + + const auto list_tlv = asn1::BerSpanWriter::tlv_size(0, names_content); + const auto more_tlv = asn1::BerSpanWriter::tlv_size(1, 1U); + const auto service_content = add_size(list_tlv, more_tlv); + if (!service_content) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto service_tlv = asn1::BerSpanWriter::tlv_size(1, *service_content); + + const auto invoke_value = positive_integer_size(invoke_id); + const auto invoke_tlv = asn1::BerSpanWriter::tlv_size(2, invoke_value); + const auto outer_content = add_size(invoke_tlv, service_tlv); + if (!outer_content) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto required = asn1::BerSpanWriter::tlv_size(1, *outer_content); + if (!required || *required > MmsPduSpanCodec::maximum_pdu_bytes) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + asn1::BerSpanWriter writer{destination.first(*required)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, *outer_content) || + !writer.write_tlv_header( + asn1::BerClass::universal, false, 2, invoke_value) || + !write_positive_integer(writer, invoke_id) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, *service_content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, names_content)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + + for (const auto name : names) { + if (!writer.write_tlv_header( + asn1::BerClass::universal, false, 26, name.size())) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + for (const auto character : name) { + if (!writer.write_byte(static_cast( + static_cast(character)))) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + } + } + + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 1, 1U) || + !writer.write_byte(more_follows ? 0xFFU : 0x00U) || + writer.size() != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, *required, *required}; +} + +bool MmsServiceSpanCodec::try_decode_variable_access_attributes_request( + const MmsConfirmedPduView& confirmed, + MmsVariableAccessAttributesRequestView& request) noexcept { + request = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_request || + confirmed.service_tag != 6 || !confirmed.service_constructed) { + return false; + } + asn1::BerTlvView wrapper; + if (!asn1::BerSpanReader::try_read_exact(confirmed.service_value, wrapper) || + wrapper.tag_class != asn1::BerClass::context_specific || + wrapper.tag_number != 0 || !wrapper.constructed || + !try_decode_object_name_view(wrapper.value, request.name)) { + request = {}; + return false; + } + request.invoke_id = confirmed.invoke_id; + return true; +} + +bool MmsServiceSpanCodec::try_decode_variable_access_attributes_request( + const std::span mms_pdu, + MmsVariableAccessAttributesRequestView& request) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_request_view(mms_pdu, confirmed) && + try_decode_variable_access_attributes_request(confirmed, request); +} + +bool MmsServiceSpanCodec::try_decode_variable_access_attributes_response( + const MmsConfirmedPduView& confirmed, + MmsVariableAccessAttributesResponseView& response) noexcept { + response = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_response || + confirmed.service_tag != 6 || !confirmed.service_constructed) { + return false; + } + bool have_deletable = false; + bool have_type = false; + std::size_t offset = 0U; + while (offset < confirmed.service_value.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv(confirmed.service_value, offset, field) || + field.tag_class != asn1::BerClass::context_specific) { + response = {}; + return false; + } + if (field.tag_number == 0 && !field.constructed) { + if (have_deletable || field.value.size() != 1U) { + response = {}; + return false; + } + response.mms_deletable = field.value[0] != 0U; + have_deletable = true; + } else if (field.tag_number == 2 && field.constructed) { + if (have_type) { + response = {}; + return false; + } + asn1::BerTlvView type; + if (!asn1::BerSpanReader::try_read_exact(field.value, type) || + type.tag_class != asn1::BerClass::context_specific) { + response = {}; + return false; + } + response.type_specification = field.value; + have_type = true; + } else { + response = {}; + return false; + } + } + if (!have_type) { + response = {}; + return false; + } + response.invoke_id = confirmed.invoke_id; + return true; +} + +bool MmsServiceSpanCodec::try_decode_variable_access_attributes_response( + const std::span mms_pdu, + MmsVariableAccessAttributesResponseView& response) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_response_view(mms_pdu, confirmed) && + try_decode_variable_access_attributes_response(confirmed, response); +} + +wire::EncodeResult MmsServiceSpanCodec::encode_variable_access_attributes_response_into( + const std::uint32_t invoke_id, + const bool mms_deletable, + const std::span encoded_type_specification, + const std::span destination) noexcept { + asn1::BerTlvView type; + if (!asn1::BerSpanReader::try_read_exact(encoded_type_specification, type) || + type.tag_class != asn1::BerClass::context_specific) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto deletable_tlv = asn1::BerSpanWriter::tlv_size(0, 1U); + const auto type_wrapper = asn1::BerSpanWriter::tlv_size( + 2, encoded_type_specification.size()); + const auto service_content = add_size(deletable_tlv, type_wrapper); + if (!service_content) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto sizing = confirmed_response_sizing(invoke_id, 6, *service_content); + if (!sizing) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < sizing->required) { + return {wire::EncodeStatus::buffer_too_small, 0U, sizing->required}; + } + + asn1::BerSpanWriter writer{destination.first(sizing->required)}; + if (!write_confirmed_response_prefix( + writer, *sizing, invoke_id, 6, *service_content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, 1U) || + !writer.write_byte(mms_deletable ? 0xFFU : 0x00U) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 2, + encoded_type_specification.size()) || + !writer.write_bytes(encoded_type_specification) || + writer.size() != sizing->required) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + return {wire::EncodeStatus::ok, sizing->required, sizing->required}; +} + +bool MmsServiceSpanCodec::try_decode_read_request( + const MmsConfirmedPduView& confirmed, + MmsReadRequestView& request) noexcept { + request = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_request || + confirmed.service_tag != 4 || !confirmed.service_constructed) { + return false; + } + + bool have_flag = false; + bool have_specification = false; + std::size_t offset = 0U; + while (offset < confirmed.service_value.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv(confirmed.service_value, offset, field) || + field.tag_class != asn1::BerClass::context_specific) { + request = {}; + return false; + } + if (field.tag_number == 0 && !field.constructed) { + if (have_flag || field.value.size() != 1U) { + request = {}; + return false; + } + request.specification_with_result = field.value[0] != 0U; + have_flag = true; + } else if (field.tag_number == 1 && field.constructed) { + if (have_specification) { + request = {}; + return false; + } + asn1::BerTlvView list; + if (!asn1::BerSpanReader::try_read_exact(field.value, list) || + list.tag_class != asn1::BerClass::context_specific || + list.tag_number != 0 || !list.constructed) { + request = {}; + return false; + } + std::size_t variable_offset = 0U; + std::size_t count = 0U; + while (variable_offset < list.value.size()) { + if (count >= maximum_variables) { + request = {}; + return false; + } + const auto before = variable_offset; + asn1::BerTlvView definition; + if (!asn1::BerSpanReader::try_read_tlv( + list.value, variable_offset, definition)) { + request = {}; + return false; + } + MmsObjectNameView ignored; + if (!try_read_variable_definition( + list.value.subspan(before, variable_offset - before), 0U, ignored)) { + request = {}; + return false; + } + ++count; + } + if (count == 0U) { + request = {}; + return false; + } + request.variable_list = list.value; + request.variable_count = count; + have_specification = true; + } else { + request = {}; + return false; + } + } + if (!have_specification) { + request = {}; + return false; + } + request.invoke_id = confirmed.invoke_id; + return true; +} + +bool MmsServiceSpanCodec::try_decode_read_request( + const std::span mms_pdu, + MmsReadRequestView& request) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_request_view(mms_pdu, confirmed) && + try_decode_read_request(confirmed, request); +} + +bool MmsServiceSpanCodec::try_decode_read_response( + const MmsConfirmedPduView& confirmed, + MmsReadResponseView& response) noexcept { + response = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_response || + confirmed.service_tag != 4 || !confirmed.service_constructed) { + return false; + } + bool have_list = false; + bool have_optional_specification = false; + std::size_t offset = 0U; + while (offset < confirmed.service_value.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv(confirmed.service_value, offset, field) || + field.tag_class != asn1::BerClass::context_specific) { + response = {}; + return false; + } + if (field.tag_number == 0 && field.constructed) { + if (have_optional_specification) { + response = {}; + return false; + } + have_optional_specification = true; + } else if (field.tag_number == 1 && field.constructed) { + if (have_list) { + response = {}; + return false; + } + std::size_t result_offset = 0U; + std::size_t count = 0U; + while (result_offset < field.value.size()) { + if (count >= maximum_variables) { + response = {}; + return false; + } + const auto start = result_offset; + asn1::BerTlvView result; + if (!asn1::BerSpanReader::try_read_tlv( + field.value, result_offset, result)) { + response = {}; + return false; + } + if (result.tag_class == asn1::BerClass::context_specific && + result.tag_number == 0 && !result.constructed) { + std::uint32_t ignored{}; + if (!read_u32(result, ignored)) { + response = {}; + return false; + } + } else if (!valid_mms_data_tlv(result)) { + response = {}; + return false; + } + if (result_offset <= start) { + response = {}; + return false; + } + ++count; + } + if (count == 0U) { + response = {}; + return false; + } + response.access_result_list = field.value; + response.result_count = count; + have_list = true; + } else { + response = {}; + return false; + } + } + if (!have_list) { + response = {}; + return false; + } + response.invoke_id = confirmed.invoke_id; + return true; +} + +bool MmsServiceSpanCodec::try_decode_read_response( + const std::span mms_pdu, + MmsReadResponseView& response) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_response_view(mms_pdu, confirmed) && + try_decode_read_response(confirmed, response); +} + +wire::EncodeResult MmsServiceSpanCodec::encode_read_response_into( + const std::uint32_t invoke_id, + const std::span results, + const std::span destination) noexcept { + if (results.empty() || results.size() > maximum_variables) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + std::size_t list_content = 0U; + for (const auto& result : results) { + std::size_t encoded_size = 0U; + if (result.success) { + asn1::BerTlvView data; + if (!asn1::BerSpanReader::try_read_exact(result.encoded_data, data) || + !valid_mms_data_tlv(data)) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + encoded_size = result.encoded_data.size(); + } else { + const auto failure_value = positive_integer_size(result.failure_code); + const auto failure_tlv = asn1::BerSpanWriter::tlv_size(0, failure_value); + if (!failure_tlv) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + encoded_size = *failure_tlv; + } + if (encoded_size > std::numeric_limits::max() - list_content) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + list_content += encoded_size; + } + + const auto list_tlv = asn1::BerSpanWriter::tlv_size(1, list_content); + if (!list_tlv) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto sizing = confirmed_response_sizing(invoke_id, 4, *list_tlv); + if (!sizing) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < sizing->required) { + return {wire::EncodeStatus::buffer_too_small, 0U, sizing->required}; + } + + asn1::BerSpanWriter writer{destination.first(sizing->required)}; + if (!write_confirmed_response_prefix( + writer, *sizing, invoke_id, 4, *list_tlv) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, list_content)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + for (const auto& result : results) { + if (result.success) { + if (!writer.write_bytes(result.encoded_data)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + } else { + const auto failure_value = positive_integer_size(result.failure_code); + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, failure_value) || + !write_positive_integer(writer, result.failure_code)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + } + } + if (writer.size() != sizing->required) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + return {wire::EncodeStatus::ok, sizing->required, sizing->required}; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_brcb_checkpoint_store.cpp b/src/mms/static_brcb_checkpoint_store.cpp new file mode 100644 index 000000000..cf9e4ccf5 --- /dev/null +++ b/src/mms/static_brcb_checkpoint_store.cpp @@ -0,0 +1,395 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_brcb_checkpoint_store.hpp" + +#include "ariec61850/mms/static_brcb_runtime.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +constexpr std::array kHeaderMagic{ + 'A','R','B','R','C','B','P','1'}; +constexpr std::array kFooterMagic{ + 'A','R','C','O','M','M','I','T'}; +constexpr std::size_t kCrcChunkBytes = 256U; + +struct BankInfo final { + bool valid{}; + bool io_error{}; + std::uint64_t generation{}; + std::uint32_t payload_bytes{}; + std::uint32_t payload_crc{}; +}; + +void write_u16( + const std::span destination, + const std::size_t offset, + const std::uint16_t value) noexcept { + destination[offset] = static_cast((value >> 8U) & 0xFFU); + destination[offset + 1U] = static_cast(value & 0xFFU); +} + +void write_u32( + const std::span destination, + const std::size_t offset, + const std::uint32_t value) noexcept { + for (std::size_t index = 0U; index < 4U; ++index) { + const auto shift = static_cast((3U - index) * 8U); + destination[offset + index] = static_cast((value >> shift) & 0xFFU); + } +} + +void write_u64( + const std::span destination, + const std::size_t offset, + const std::uint64_t value) noexcept { + for (std::size_t index = 0U; index < 8U; ++index) { + const auto shift = static_cast((7U - index) * 8U); + destination[offset + index] = static_cast((value >> shift) & 0xFFU); + } +} + +[[nodiscard]] std::uint16_t read_u16( + const std::span source, + const std::size_t offset) noexcept { + return static_cast( + (static_cast(source[offset]) << 8U) | + static_cast(source[offset + 1U])); +} + +[[nodiscard]] std::uint32_t read_u32( + const std::span source, + const std::size_t offset) noexcept { + std::uint32_t value = 0U; + for (std::size_t index = 0U; index < 4U; ++index) { + value = static_cast((value << 8U) | source[offset + index]); + } + return value; +} + +[[nodiscard]] std::uint64_t read_u64( + const std::span source, + const std::size_t offset) noexcept { + std::uint64_t value = 0U; + for (std::size_t index = 0U; index < 8U; ++index) { + value = (value << 8U) | source[offset + index]; + } + return value; +} + +[[nodiscard]] std::uint32_t crc32_update( + std::uint32_t state, + const std::span bytes) noexcept { + for (const auto byte : bytes) { + state ^= byte; + for (std::uint8_t bit = 0U; bit < 8U; ++bit) { + const auto mask = static_cast( + 0U - static_cast(state & 1U)); + state = (state >> 1U) ^ (0xEDB88320U & mask); + } + } + return state; +} + +[[nodiscard]] std::uint32_t crc32( + const std::span bytes) noexcept { + return ~crc32_update(0xFFFF'FFFFU, bytes); +} + +[[nodiscard]] bool generation_newer( + const std::uint64_t left, + const std::uint64_t right) noexcept { + if (left == right) { + return false; + } + return static_cast(left - right) < (std::uint64_t{1U} << 63U); +} + +[[nodiscard]] std::uint64_t next_generation(const std::uint64_t current) noexcept { + return current == 0U || current == std::numeric_limits::max() + ? 1U + : current + 1U; +} + +[[nodiscard]] std::size_t bank_offset( + const std::size_t bank_index, + const std::size_t bank_bytes) noexcept { + return bank_index * bank_bytes; +} + +[[nodiscard]] bool read_payload_crc( + const MmsStaticBrcbStorageBackend& backend, + const std::size_t payload_offset, + const std::size_t payload_bytes, + std::uint32_t& result) noexcept { + std::array chunk{}; + std::size_t consumed = 0U; + std::uint32_t state = 0xFFFF'FFFFU; + while (consumed < payload_bytes) { + const auto remaining = payload_bytes - consumed; + const auto bytes = std::min(remaining, chunk.size()); + if (!backend.read( + backend.context, + payload_offset + consumed, + std::span{chunk}.first(bytes))) { + return false; + } + state = crc32_update( + state, + std::span{chunk}.first(bytes)); + consumed += bytes; + } + result = ~state; + return true; +} + +[[nodiscard]] BankInfo inspect_bank( + const MmsStaticBrcbStorageBackend& backend, + const std::size_t bank_index, + const std::size_t bank_bytes) noexcept { + BankInfo info; + std::array header{}; + std::array footer{}; + const auto base = bank_offset(bank_index, bank_bytes); + const auto footer_offset = base + bank_bytes - footer.size(); + if (!backend.read(backend.context, base, header) || + !backend.read(backend.context, footer_offset, footer)) { + info.io_error = true; + return info; + } + + if (!std::equal(kHeaderMagic.begin(), kHeaderMagic.end(), header.begin()) || + read_u16(header, 8U) != MmsStaticBrcbCheckpointStore::format_version || + read_u16(header, 10U) != MmsStaticBrcbCheckpointStore::record_header_bytes || + crc32(std::span{header}.first(36U)) != read_u32(header, 36U) || + !std::equal(kFooterMagic.begin(), kFooterMagic.end(), footer.begin()) || + crc32(std::span{footer}.first(28U)) != read_u32(footer, 28U)) { + return info; + } + for (std::size_t index = 28U; index < 36U; ++index) { + if (header[index] != 0U) { + return info; + } + } + for (std::size_t index = 24U; index < 28U; ++index) { + if (footer[index] != 0U) { + return info; + } + } + + const auto generation = read_u64(header, 12U); + const auto payload_bytes = read_u32(header, 20U); + const auto payload_crc = read_u32(header, 24U); + if (generation == 0U || payload_bytes == 0U || + generation != read_u64(footer, 8U) || + payload_bytes != read_u32(footer, 16U) || + payload_crc != read_u32(footer, 20U) || + static_cast(payload_bytes) > + bank_bytes - MmsStaticBrcbCheckpointStore::record_header_bytes - + MmsStaticBrcbCheckpointStore::record_footer_bytes) { + return info; + } + + std::uint32_t measured_crc = 0U; + if (!read_payload_crc( + backend, + base + MmsStaticBrcbCheckpointStore::record_header_bytes, + payload_bytes, + measured_crc)) { + info.io_error = true; + return info; + } + if (measured_crc != payload_crc) { + return info; + } + + info.valid = true; + info.generation = generation; + info.payload_bytes = payload_bytes; + info.payload_crc = payload_crc; + return info; +} + +void build_header( + const std::uint64_t generation, + const std::uint32_t payload_bytes, + const std::uint32_t payload_crc, + std::span header) noexcept { + std::fill(header.begin(), header.end(), std::uint8_t{0U}); + std::copy(kHeaderMagic.begin(), kHeaderMagic.end(), header.begin()); + write_u16(header, 8U, MmsStaticBrcbCheckpointStore::format_version); + write_u16( + header, + 10U, + static_cast(MmsStaticBrcbCheckpointStore::record_header_bytes)); + write_u64(header, 12U, generation); + write_u32(header, 20U, payload_bytes); + write_u32(header, 24U, payload_crc); + write_u32(header, 36U, crc32(std::span{header}.first(36U))); +} + +void build_footer( + const std::uint64_t generation, + const std::uint32_t payload_bytes, + const std::uint32_t payload_crc, + std::span footer) noexcept { + std::fill(footer.begin(), footer.end(), std::uint8_t{0U}); + std::copy(kFooterMagic.begin(), kFooterMagic.end(), footer.begin()); + write_u64(footer, 8U, generation); + write_u32(footer, 16U, payload_bytes); + write_u32(footer, 20U, payload_crc); + write_u32(footer, 28U, crc32(std::span{footer}.first(28U))); +} + +} // namespace + +bool MmsStaticBrcbCheckpointStore::valid() const noexcept { + return backend_.valid() && + bank_bytes_ > record_header_bytes + record_footer_bytes && + bank_bytes_ <= backend_.capacity_bytes / bank_count; +} + +MmsStaticBrcbCheckpointResult MmsStaticBrcbCheckpointStore::checkpoint( + const MmsStaticBrcbRuntime& runtime, + const std::span state_buffer) const noexcept { + MmsStaticBrcbCheckpointResult result; + if (!valid()) { + result.status = MmsStaticBrcbCheckpointStatus::invalid_backend; + return result; + } + + const auto state = MmsStaticBrcbStateCodec::encode(runtime, state_buffer); + result.state_status = state.status; + result.required_state_bytes = state.required_bytes; + if (!state.success()) { + result.status = state.status == MmsStaticBrcbStateStatus::buffer_too_small + ? MmsStaticBrcbCheckpointStatus::state_buffer_too_small + : MmsStaticBrcbCheckpointStatus::state_encode_failed; + return result; + } + if (state.bytes_written > maximum_state_bytes() || + state.bytes_written > std::numeric_limits::max()) { + result.status = MmsStaticBrcbCheckpointStatus::invalid_layout; + result.state_bytes = state.bytes_written; + return result; + } + + const auto first = inspect_bank(backend_, 0U, bank_bytes_); + const auto second = inspect_bank(backend_, 1U, bank_bytes_); + if (first.io_error || second.io_error) { + result.status = MmsStaticBrcbCheckpointStatus::storage_failure; + return result; + } + + std::size_t target = 0U; + std::uint64_t current_generation = 0U; + if (first.valid && second.valid) { + const auto newest = generation_newer(second.generation, first.generation) ? 1U : 0U; + target = 1U - newest; + current_generation = newest == 0U ? first.generation : second.generation; + } else if (first.valid) { + target = 1U; + current_generation = first.generation; + } else if (second.valid) { + target = 0U; + current_generation = second.generation; + } + const auto generation = next_generation(current_generation); + const auto payload_bytes = static_cast(state.bytes_written); + const auto payload_crc = crc32(state_buffer.first(state.bytes_written)); + + std::array header{}; + std::array footer{}; + build_header(generation, payload_bytes, payload_crc, header); + build_footer(generation, payload_bytes, payload_crc, footer); + + const auto base = bank_offset(target, bank_bytes_); + const auto footer_offset = base + bank_bytes_ - footer.size(); + if (!backend_.erase(backend_.context, base, bank_bytes_) || + !backend_.sync(backend_.context) || + !backend_.write(backend_.context, base, header) || + !backend_.write( + backend_.context, + base + record_header_bytes, + state_buffer.first(state.bytes_written)) || + !backend_.sync(backend_.context) || + !backend_.write(backend_.context, footer_offset, footer) || + !backend_.sync(backend_.context)) { + result.status = MmsStaticBrcbCheckpointStatus::storage_failure; + result.generation = generation; + result.bank_index = target; + result.state_bytes = state.bytes_written; + return result; + } + + result.status = MmsStaticBrcbCheckpointStatus::ok; + result.generation = generation; + result.bank_index = target; + result.state_bytes = state.bytes_written; + return result; +} + +MmsStaticBrcbCheckpointResult MmsStaticBrcbCheckpointStore::restore( + MmsStaticBrcbRuntime& runtime, + const std::span state_buffer) const noexcept { + MmsStaticBrcbCheckpointResult result; + if (!valid()) { + result.status = MmsStaticBrcbCheckpointStatus::invalid_backend; + return result; + } + + const auto first = inspect_bank(backend_, 0U, bank_bytes_); + const auto second = inspect_bank(backend_, 1U, bank_bytes_); + if (first.io_error || second.io_error) { + result.status = MmsStaticBrcbCheckpointStatus::storage_failure; + return result; + } + if (!first.valid && !second.valid) { + result.status = MmsStaticBrcbCheckpointStatus::no_checkpoint; + return result; + } + + std::size_t selected = 0U; + BankInfo info = first; + if (!first.valid || (second.valid && generation_newer(second.generation, first.generation))) { + selected = 1U; + info = second; + } + result.generation = info.generation; + result.bank_index = selected; + result.state_bytes = info.payload_bytes; + result.required_state_bytes = info.payload_bytes; + if (state_buffer.size() < info.payload_bytes) { + result.status = MmsStaticBrcbCheckpointStatus::state_buffer_too_small; + return result; + } + + const auto base = bank_offset(selected, bank_bytes_); + auto payload = state_buffer.first(info.payload_bytes); + if (!backend_.read( + backend_.context, + base + record_header_bytes, + payload) || + crc32(payload) != info.payload_crc) { + result.status = MmsStaticBrcbCheckpointStatus::storage_failure; + return result; + } + + const auto restored = MmsStaticBrcbStateCodec::restore(runtime, payload); + result.state_status = restored.status; + if (!restored.success()) { + result.status = MmsStaticBrcbCheckpointStatus::state_restore_failed; + return result; + } + result.status = MmsStaticBrcbCheckpointStatus::ok; + return result; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_brcb_connection.cpp b/src/mms/static_brcb_connection.cpp new file mode 100644 index 000000000..e5a82557a --- /dev/null +++ b/src/mms/static_brcb_connection.cpp @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_brcb_connection.hpp" + +#include "ariec61850/osi/cotp_span.hpp" +#include "ariec61850/osi/presentation_span.hpp" +#include "ariec61850/osi/tpkt_span.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] bool add_size( + const std::size_t base, + const std::size_t extra, + std::size_t& total) noexcept { + if (extra > std::numeric_limits::max() - base) { + total = 0U; + return false; + } + total = base + extra; + return true; +} + +[[nodiscard]] MmsStaticBrcbConnectionResult make_result( + const MmsStaticBrcbConnectionStatus status, + const MmsStaticBrcbEntryView* entry = nullptr) noexcept { + MmsStaticBrcbConnectionResult result; + result.status = status; + if (entry != nullptr) { + if (entry->entry_id.size() == result.entry_id.size()) { + std::copy(entry->entry_id.begin(), entry->entry_id.end(), result.entry_id.begin()); + } + result.sequence_number = entry->sequence_number; + result.buffer_overflow = entry->buffer_overflow; + } + return result; +} + +} // namespace + +MmsStaticBrcbConnectionResult MmsStaticBrcbConnection::poll( + const MmsStaticConnectionRuntime& connection, + MmsStaticBrcbRuntime& reports, + const std::span response, + const std::span workspace) noexcept { + if (connection.state() != MmsStaticConnectionState::established || + connection.mms_presentation_context_id() == 0U) { + return make_result(MmsStaticBrcbConnectionStatus::not_established); + } + if (!reports.enabled()) { + return make_result(MmsStaticBrcbConnectionStatus::reporting_disabled); + } + + MmsStaticBrcbEntryView entry; + if (!reports.front(entry)) { + return make_result(MmsStaticBrcbConnectionStatus::no_report_available); + } + + const auto p_data = osi::PresentationSpanCodec::encode_p_data_into( + entry.mms_pdu, + workspace, + connection.mms_presentation_context_id(), + true); + if (!p_data.success()) { + if (p_data.status != wire::EncodeStatus::buffer_too_small) { + return make_result(MmsStaticBrcbConnectionStatus::frame_encode_failed, &entry); + } + std::size_t final_required{}; + if (!add_size(p_data.required_bytes, 7U, final_required)) { + return make_result(MmsStaticBrcbConnectionStatus::frame_encode_failed, &entry); + } + auto result = make_result( + MmsStaticBrcbConnectionStatus::workspace_too_small, &entry); + result.required_workspace_bytes = final_required; + result.required_response_bytes = final_required; + return result; + } + + std::size_t final_required{}; + if (!add_size(p_data.bytes_written, 7U, final_required)) { + return make_result(MmsStaticBrcbConnectionStatus::frame_encode_failed, &entry); + } + if (response.size() < final_required) { + auto result = make_result( + MmsStaticBrcbConnectionStatus::response_buffer_too_small, &entry); + result.required_response_bytes = final_required; + return result; + } + if (workspace.size() < final_required) { + auto result = make_result( + MmsStaticBrcbConnectionStatus::workspace_too_small, &entry); + result.required_response_bytes = final_required; + result.required_workspace_bytes = final_required; + return result; + } + + const auto cotp = osi::CotpSpanCodec::encode_data_into( + workspace.first(p_data.bytes_written), + response.first(final_required - osi::TpktSpanCodec::header_length)); + if (!cotp.success()) { + return make_result(MmsStaticBrcbConnectionStatus::frame_encode_failed, &entry); + } + + const auto tpkt = osi::TpktSpanCodec::encode_into( + response.first(cotp.bytes_written), + workspace.first(final_required)); + if (!tpkt.success() || tpkt.bytes_written != final_required) { + return make_result(MmsStaticBrcbConnectionStatus::frame_encode_failed, &entry); + } + + auto result = make_result(MmsStaticBrcbConnectionStatus::response_ready, &entry); + const auto committed = reports.commit_delivery(entry.entry_id); + if (committed != MmsStaticBrcbStatus::ok) { + result.status = MmsStaticBrcbConnectionStatus::stale_entry; + return result; + } + + std::copy_n(workspace.begin(), tpkt.bytes_written, response.begin()); + result.bytes_written = tpkt.bytes_written; + result.required_response_bytes = final_required; + result.required_workspace_bytes = final_required; + return result; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_brcb_runtime.cpp b/src/mms/static_brcb_runtime.cpp new file mode 100644 index 000000000..dff27bc3f --- /dev/null +++ b/src/mms/static_brcb_runtime.cpp @@ -0,0 +1,476 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_brcb_runtime.hpp" + +#include "ariec61850/mms/buffered_selective_report_detail.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +constexpr std::uint8_t kTriggerDataChange = 0x40U; +constexpr std::uint8_t kTriggerQualityChange = 0x20U; +constexpr std::uint8_t kTriggerDataUpdate = 0x10U; +constexpr std::uint8_t kAllowedTriggers = 0x70U; + +constexpr std::uint8_t kReasonDataChange = 0x80U; +constexpr std::uint8_t kReasonQualityChange = 0x40U; +constexpr std::uint8_t kReasonDataUpdate = 0x20U; + +constexpr std::uint8_t kOptReasonForInclusion = 0x10U; +constexpr std::uint8_t kOptBufferOverflow = 0x02U; +constexpr std::uint8_t kOptEntryId = 0x01U; +constexpr std::uint8_t kAllowedOptionalFirst = 0x7FU; +constexpr std::uint8_t kAllowedOptionalSecond = 0x80U; + +[[nodiscard]] std::uint64_t saturating_add( + const std::uint64_t base, + const std::uint64_t delta) noexcept { + return delta > std::numeric_limits::max() - base + ? std::numeric_limits::max() + : base + delta; +} + +void bump_revision(std::uint32_t& revision) noexcept { + revision = revision == std::numeric_limits::max() + ? 1U + : revision + 1U; +} + +[[nodiscard]] std::uint8_t next_sequence_number( + const std::uint8_t current) noexcept { + return static_cast( + (static_cast(current) + 1U) & 0xFFU); +} + +[[nodiscard]] std::uint64_t next_entry_number( + const std::uint64_t current) noexcept { + return current == std::numeric_limits::max() + ? 1U + : current + 1U; +} + +[[nodiscard]] std::span as_bytes( + const std::string_view text) noexcept { + return { + reinterpret_cast(text.data()), + text.size()}; +} + +[[nodiscard]] MmsObjectNameView object_name( + const std::string_view domain, + const std::string_view item) noexcept { + return { + MmsObjectNameViewKind::domain_specific, + as_bytes(domain), + as_bytes(item)}; +} + +[[nodiscard]] bool visible_ascii( + const std::string_view text, + const std::size_t maximum_bytes) noexcept { + if (text.empty() || text.size() > maximum_bytes) { + return false; + } + for (const auto character : text) { + const auto byte = static_cast(character); + if (byte == 0U || byte > 0x7FU) { + return false; + } + } + return true; +} + +[[nodiscard]] bool valid_reference( + const std::string_view domain, + const std::string_view item) noexcept { + return visible_ascii(domain, MmsInformationReportSpanCodec::maximum_reference_bytes) && + visible_ascii(item, MmsInformationReportSpanCodec::maximum_reference_bytes) && + domain.size() + 1U + item.size() <= + MmsInformationReportSpanCodec::maximum_reference_bytes; +} + +void clear_pending(MmsStaticBrcbPendingState& pending) noexcept { + std::fill( + pending.member_reason_masks.begin(), + pending.member_reason_masks.end(), + std::uint8_t{0U}); + pending.due_ms = 0U; + pending.pending_member_count = 0U; + pending.pending = false; +} + +[[nodiscard]] bool event_mapping( + const MmsStaticBrcbEventReason reason, + std::uint8_t& trigger_mask, + std::uint8_t& report_reason) noexcept { + switch (reason) { + case MmsStaticBrcbEventReason::data_change: + trigger_mask = kTriggerDataChange; + report_reason = kReasonDataChange; + return true; + case MmsStaticBrcbEventReason::quality_change: + trigger_mask = kTriggerQualityChange; + report_reason = kReasonQualityChange; + return true; + case MmsStaticBrcbEventReason::data_update: + trigger_mask = kTriggerDataUpdate; + report_reason = kReasonDataUpdate; + return true; + } + return false; +} + +[[nodiscard]] std::array +encode_entry_id(const std::uint64_t entry_number) noexcept { + std::array result{}; + for (std::size_t index = 0U; index < result.size(); ++index) { + const auto byte_index = result.size() - 1U - index; + const auto shift = static_cast(index * 8U); + result[byte_index] = static_cast( + (entry_number >> shift) & 0xFFU); + } + return result; +} + +[[nodiscard]] bool entry_id_equal( + const std::span left, + const std::array& right) + noexcept { + return left.size() == right.size() && + std::equal(left.begin(), left.end(), right.begin()); +} + +} // namespace + +bool MmsStaticBrcbRuntime::initialize() noexcept { + initialized_ = false; + if (definition_ == nullptr || pending_ == nullptr || objects_ == nullptr || + data_sets_ == nullptr || !objects_->valid() || !data_sets_->valid() || + !data_sets_->valid_against(*objects_) || slots_.empty()) { + return false; + } + if (!valid_reference(definition_->domain, definition_->item) || + !visible_ascii( + definition_->report_id, + MmsInformationReportSpanCodec::maximum_report_id_bytes) || + !valid_reference(definition_->data_set_domain, definition_->data_set_item) || + (definition_->optional_fields[0] & + static_cast(~kAllowedOptionalFirst)) != 0U || + (definition_->optional_fields[1] & + static_cast(~kAllowedOptionalSecond)) != 0U || + (definition_->optional_fields[0] & + static_cast(kOptBufferOverflow | kOptEntryId)) != + static_cast(kOptBufferOverflow | kOptEntryId) || + definition_->trigger_options == 0U || + (definition_->trigger_options & static_cast(~kAllowedTriggers)) != 0U) { + return false; + } + + const auto* data_set = data_sets_->find(object_name( + definition_->data_set_domain, definition_->data_set_item)); + if (data_set == nullptr || data_set->members.empty() || + data_set->members.size() > MmsInformationReportSpanCodec::maximum_members) { + return false; + } + for (const auto& slot : slots_) { + if (slot.storage.empty()) { + return false; + } + } + + *pending_ = {}; + pending_->revision = 1U; + for (auto& slot : slots_) { + const auto storage = slot.storage; + slot = {}; + slot.storage = storage; + } + head_ = 0U; + count_ = 0U; + next_entry_number_ = 1U; + dropped_reports_ = 0U; + queue_revision_ = 1U; + sequence_number_ = 0U; + enabled_ = false; + initialized_ = true; + return true; +} + +MmsStaticBrcbStatus MmsStaticBrcbRuntime::set_enabled( + const bool enabled) noexcept { + if (!initialized_ || pending_ == nullptr) { + return MmsStaticBrcbStatus::invalid_runtime; + } + if (enabled_ == enabled) { + return MmsStaticBrcbStatus::ok; + } + enabled_ = enabled; + clear_pending(*pending_); + bump_revision(pending_->revision); + return MmsStaticBrcbStatus::ok; +} + +MmsStaticBrcbStatus MmsStaticBrcbRuntime::notify( + const std::size_t data_set_member_index, + const MmsStaticBrcbEventReason reason, + const std::uint64_t now_ms) noexcept { + if (!initialized_ || definition_ == nullptr || pending_ == nullptr || + data_sets_ == nullptr) { + return MmsStaticBrcbStatus::invalid_runtime; + } + if (!enabled_) { + return MmsStaticBrcbStatus::temporarily_unavailable; + } + + std::uint8_t trigger_mask = 0U; + std::uint8_t report_reason = 0U; + if (!event_mapping(reason, trigger_mask, report_reason) || + (definition_->trigger_options & trigger_mask) == 0U) { + return MmsStaticBrcbStatus::trigger_not_selected; + } + + const auto* data_set = data_sets_->find(object_name( + definition_->data_set_domain, definition_->data_set_item)); + if (data_set == nullptr) { + return MmsStaticBrcbStatus::data_set_not_found; + } + if (data_set_member_index >= data_set->members.size() || + data_set_member_index >= MmsInformationReportSpanCodec::maximum_members) { + return MmsStaticBrcbStatus::member_out_of_range; + } + + if (!pending_->pending) { + pending_->pending = true; + pending_->due_ms = saturating_add(now_ms, definition_->buffer_time_ms); + } + if (pending_->member_reason_masks[data_set_member_index] == 0U) { + ++pending_->pending_member_count; + } + pending_->member_reason_masks[data_set_member_index] = static_cast( + pending_->member_reason_masks[data_set_member_index] | report_reason); + bump_revision(pending_->revision); + return MmsStaticBrcbStatus::ok; +} + +bool MmsStaticBrcbRuntime::next_due( + const std::uint64_t now_ms, + MmsStaticBrcbCapturePlan& plan) const noexcept { + plan = {}; + if (!initialized_ || pending_ == nullptr || !enabled_ || + !pending_->pending || pending_->pending_member_count == 0U || + now_ms < pending_->due_ms || slots_.empty()) { + return false; + } + plan.pending_revision = pending_->revision; + plan.queue_revision = queue_revision_; + plan.entry_number = next_entry_number_; + plan.sequence_number = next_sequence_number(sequence_number_); + plan.buffer_overflow = count_ == slots_.size(); + return true; +} + +MmsStaticBrcbCaptureResult MmsStaticBrcbRuntime::capture( + const MmsStaticBrcbCapturePlan& plan, + const std::span report_time, + const std::span encode_buffer, + const std::span workspace) noexcept { + MmsStaticBrcbCaptureResult result; + if (!initialized_ || definition_ == nullptr || pending_ == nullptr || + objects_ == nullptr || data_sets_ == nullptr || slots_.empty()) { + result.status = MmsStaticBrcbStatus::invalid_runtime; + return result; + } + const bool full = count_ == slots_.size(); + if (!enabled_ || !pending_->pending || pending_->pending_member_count == 0U || + plan.pending_revision != pending_->revision || + plan.queue_revision != queue_revision_ || + plan.entry_number != next_entry_number_ || + plan.sequence_number != next_sequence_number(sequence_number_) || + plan.buffer_overflow != full) { + result.status = MmsStaticBrcbStatus::stale_plan; + return result; + } + + const auto* data_set = data_sets_->find(object_name( + definition_->data_set_domain, definition_->data_set_item)); + if (data_set == nullptr) { + result.status = MmsStaticBrcbStatus::data_set_not_found; + return result; + } + + std::array indices{}; + std::array references{}; + std::array results{}; + std::array reasons{}; + + std::size_t included = 0U; + std::size_t workspace_offset = 0U; + for (std::size_t member_index = 0U; + member_index < data_set->members.size(); + ++member_index) { + const auto reason_mask = pending_->member_reason_masks[member_index]; + if (reason_mask == 0U) { + continue; + } + const auto& member = data_set->members[member_index]; + const auto* object = objects_->find(object_name(member.domain, member.item)); + if (object == nullptr) { + result.status = MmsStaticBrcbStatus::object_not_found; + result.included_member_count = included; + return result; + } + const auto remaining = workspace.subspan(workspace_offset); + const auto read = object->read(object->context, remaining); + if (read.status == wire::EncodeStatus::buffer_too_small) { + result.status = MmsStaticBrcbStatus::workspace_too_small; + if (read.required_bytes <= + std::numeric_limits::max() - workspace_offset) { + result.required_bytes = workspace_offset + read.required_bytes; + } + result.included_member_count = included; + return result; + } + if (!read.success() || read.bytes_written > remaining.size() || + !detail::valid_mms_data(remaining.first(read.bytes_written))) { + result.status = MmsStaticBrcbStatus::backend_failure; + result.included_member_count = included; + return result; + } + + indices[included] = member_index; + references[included] = {member.domain, member.item}; + results[included] = {true, remaining.first(read.bytes_written), 0U}; + reasons[included] = reason_mask; + workspace_offset += read.bytes_written; + ++included; + } + if (included == 0U || included != pending_->pending_member_count) { + result.status = MmsStaticBrcbStatus::stale_plan; + result.included_member_count = included; + return result; + } + + const auto entry_id = encode_entry_id(plan.entry_number); + MmsBufferedSelectiveInformationReportSnapshotInput report; + report.report_id = definition_->report_id; + report.optional_fields = definition_->optional_fields; + report.sequence_number = plan.sequence_number; + report.report_time = report_time; + report.data_set_reference = { + definition_->data_set_domain, definition_->data_set_item}; + report.buffer_overflow = plan.buffer_overflow; + report.entry_id = entry_id; + report.conf_revision = definition_->conf_revision; + report.data_set_member_count = data_set->members.size(); + report.included_member_indices = + std::span{indices}.first(included); + report.included_member_references = + std::span{references}.first(included); + report.included_member_results = + std::span{results}.first(included); + if ((definition_->optional_fields[0] & kOptReasonForInclusion) != 0U) { + report.included_reason_for_inclusion = + std::span{reasons}.first(included); + } + + const auto encoded = + MmsBufferedSelectiveInformationReportSpanCodec::encode_snapshot_into( + report, encode_buffer); + if (!encoded.success()) { + result.status = encoded.status == wire::EncodeStatus::buffer_too_small + ? MmsStaticBrcbStatus::response_buffer_too_small + : MmsStaticBrcbStatus::report_encode_failed; + result.required_bytes = encoded.required_bytes; + result.included_member_count = included; + result.entry_id = entry_id; + return result; + } + + const auto target_index = full + ? head_ + : (head_ + count_) % slots_.size(); + auto& slot = slots_[target_index]; + if (slot.storage.size() < encoded.bytes_written) { + result.status = MmsStaticBrcbStatus::slot_too_small; + result.required_bytes = encoded.bytes_written; + result.included_member_count = included; + result.entry_id = entry_id; + return result; + } + + std::copy_n(encode_buffer.begin(), encoded.bytes_written, slot.storage.begin()); + slot.entry_id = entry_id; + slot.bytes = encoded.bytes_written; + slot.sequence_number = plan.sequence_number; + slot.buffer_overflow = plan.buffer_overflow; + slot.occupied = true; + + if (full) { + head_ = (head_ + 1U) % slots_.size(); + ++dropped_reports_; + } else { + ++count_; + } + sequence_number_ = plan.sequence_number; + next_entry_number_ = next_entry_number(plan.entry_number); + clear_pending(*pending_); + bump_revision(pending_->revision); + bump_revision(queue_revision_); + + result.status = MmsStaticBrcbStatus::ok; + result.bytes_written = encoded.bytes_written; + result.required_bytes = encoded.required_bytes; + result.included_member_count = included; + result.entry_id = entry_id; + return result; +} + +bool MmsStaticBrcbRuntime::front(MmsStaticBrcbEntryView& entry) const noexcept { + entry = {}; + if (!initialized_ || count_ == 0U || head_ >= slots_.size()) { + return false; + } + const auto& slot = slots_[head_]; + if (!slot.occupied || slot.bytes == 0U || slot.bytes > slot.storage.size()) { + return false; + } + entry.mms_pdu = std::span{slot.storage}.first(slot.bytes); + entry.entry_id = slot.entry_id; + entry.sequence_number = slot.sequence_number; + entry.buffer_overflow = slot.buffer_overflow; + return true; +} + +MmsStaticBrcbStatus MmsStaticBrcbRuntime::commit_delivery( + const std::span expected_entry_id) noexcept { + if (!initialized_ || slots_.empty()) { + return MmsStaticBrcbStatus::invalid_runtime; + } + if (count_ == 0U || head_ >= slots_.size()) { + return MmsStaticBrcbStatus::entry_not_found; + } + auto& slot = slots_[head_]; + if (!slot.occupied || !entry_id_equal(expected_entry_id, slot.entry_id)) { + return MmsStaticBrcbStatus::entry_not_found; + } + + const auto storage = slot.storage; + slot = {}; + slot.storage = storage; + head_ = (head_ + 1U) % slots_.size(); + --count_; + bump_revision(queue_revision_); + return MmsStaticBrcbStatus::ok; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_brcb_state_codec.cpp b/src/mms/static_brcb_state_codec.cpp new file mode 100644 index 000000000..47ec8666d --- /dev/null +++ b/src/mms/static_brcb_state_codec.cpp @@ -0,0 +1,338 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_brcb_state_codec.hpp" + +#include "ariec61850/mms/static_brcb_runtime.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +constexpr std::array kMagic{ + 'A','R','B','R','C','B','Q','1'}; +constexpr std::size_t kHeaderBytes = 48U; +constexpr std::size_t kEntryHeaderBytes = 16U; +constexpr std::uint64_t kFnvOffset = 14695981039346656037ULL; +constexpr std::uint64_t kFnvPrime = 1099511628211ULL; + +void write_u16( + const std::span destination, + const std::size_t offset, + const std::uint16_t value) noexcept { + destination[offset] = static_cast((value >> 8U) & 0xFFU); + destination[offset + 1U] = static_cast(value & 0xFFU); +} + +void write_u32( + const std::span destination, + const std::size_t offset, + const std::uint32_t value) noexcept { + for (std::size_t index = 0U; index < 4U; ++index) { + const auto shift = static_cast((3U - index) * 8U); + destination[offset + index] = static_cast((value >> shift) & 0xFFU); + } +} + +void write_u64( + const std::span destination, + const std::size_t offset, + const std::uint64_t value) noexcept { + for (std::size_t index = 0U; index < 8U; ++index) { + const auto shift = static_cast((7U - index) * 8U); + destination[offset + index] = static_cast((value >> shift) & 0xFFU); + } +} + +[[nodiscard]] std::uint16_t read_u16( + const std::span source, + const std::size_t offset) noexcept { + return static_cast( + (static_cast(source[offset]) << 8U) | + static_cast(source[offset + 1U])); +} + +[[nodiscard]] std::uint32_t read_u32( + const std::span source, + const std::size_t offset) noexcept { + std::uint32_t value = 0U; + for (std::size_t index = 0U; index < 4U; ++index) { + value = static_cast((value << 8U) | source[offset + index]); + } + return value; +} + +[[nodiscard]] std::uint64_t read_u64( + const std::span source, + const std::size_t offset) noexcept { + std::uint64_t value = 0U; + for (std::size_t index = 0U; index < 8U; ++index) { + value = (value << 8U) | source[offset + index]; + } + return value; +} + +[[nodiscard]] std::uint64_t next_entry_number(const std::uint64_t value) noexcept { + return value == std::numeric_limits::max() ? 1U : value + 1U; +} + +void hash_byte(std::uint64_t& hash, const std::uint8_t value) noexcept { + hash ^= value; + hash *= kFnvPrime; +} + +void hash_u64(std::uint64_t& hash, const std::uint64_t value) noexcept { + for (std::size_t index = 0U; index < 8U; ++index) { + const auto shift = static_cast((7U - index) * 8U); + hash_byte(hash, static_cast((value >> shift) & 0xFFU)); + } +} + +void hash_text(std::uint64_t& hash, const std::string_view text) noexcept { + hash_u64(hash, static_cast(text.size())); + for (const auto character : text) { + hash_byte(hash, static_cast(character)); + } +} + +[[nodiscard]] std::uint64_t definition_fingerprint( + const MmsStaticBrcbDefinition& definition) noexcept { + std::uint64_t hash = kFnvOffset; + hash_text(hash, definition.domain); + hash_text(hash, definition.item); + hash_text(hash, definition.report_id); + hash_text(hash, definition.data_set_domain); + hash_text(hash, definition.data_set_item); + hash_u64(hash, definition.conf_revision); + for (const auto field : definition.optional_fields) { + hash_byte(hash, field); + } + hash_u64(hash, definition.buffer_time_ms); + hash_byte(hash, definition.trigger_options); + return hash; +} + +[[nodiscard]] bool add_required( + std::size_t& required, + const std::size_t extra) noexcept { + if (extra > std::numeric_limits::max() - required) { + return false; + } + required += extra; + return true; +} + +[[nodiscard]] std::uint64_t decode_entry_id( + const std::span entry_id) noexcept { + std::uint64_t value = 0U; + for (const auto byte : entry_id) { + value = (value << 8U) | byte; + } + return value; +} + +void clear_slots(const std::span slots) noexcept { + for (auto& slot : slots) { + const auto storage = slot.storage; + slot = {}; + slot.storage = storage; + } +} + +} // namespace + +MmsStaticBrcbStateResult MmsStaticBrcbStateCodec::encode( + const MmsStaticBrcbRuntime& runtime, + const std::span destination) noexcept { + MmsStaticBrcbStateResult result; + if (!runtime.initialized_ || runtime.definition_ == nullptr || + runtime.pending_ == nullptr || runtime.slots_.empty() || + runtime.count_ > runtime.slots_.size() || + runtime.count_ > std::numeric_limits::max()) { + result.status = MmsStaticBrcbStateStatus::invalid_runtime; + return result; + } + + std::size_t required = kHeaderBytes; + for (std::size_t logical = 0U; logical < runtime.count_; ++logical) { + const auto physical = (runtime.head_ + logical) % runtime.slots_.size(); + const auto& slot = runtime.slots_[physical]; + if (!slot.occupied || slot.bytes == 0U || slot.bytes > slot.storage.size() || + slot.bytes > std::numeric_limits::max() || + !add_required(required, kEntryHeaderBytes) || + !add_required(required, slot.bytes)) { + result.status = MmsStaticBrcbStateStatus::invalid_runtime; + return result; + } + } + result.required_bytes = required; + if (destination.size() < required) { + result.status = MmsStaticBrcbStateStatus::buffer_too_small; + return result; + } + + auto output = destination.first(required); + std::fill(output.begin(), output.end(), std::uint8_t{0U}); + std::copy(kMagic.begin(), kMagic.end(), output.begin()); + write_u16(output, 8U, format_version); + write_u16(output, 10U, static_cast(kHeaderBytes)); + write_u64(output, 12U, definition_fingerprint(*runtime.definition_)); + write_u32(output, 20U, static_cast(runtime.count_)); + write_u64(output, 24U, runtime.next_entry_number_); + write_u64(output, 32U, runtime.dropped_reports_); + output[40U] = runtime.sequence_number_; + + std::size_t offset = kHeaderBytes; + for (std::size_t logical = 0U; logical < runtime.count_; ++logical) { + const auto physical = (runtime.head_ + logical) % runtime.slots_.size(); + const auto& slot = runtime.slots_[physical]; + write_u32(output, offset, static_cast(slot.bytes)); + std::copy( + slot.entry_id.begin(), + slot.entry_id.end(), + output.subspan(offset + 4U).begin()); + output[offset + 12U] = slot.sequence_number; + output[offset + 13U] = slot.buffer_overflow ? 1U : 0U; + offset += kEntryHeaderBytes; + std::copy_n( + slot.storage.begin(), + slot.bytes, + output.subspan(offset).begin()); + offset += slot.bytes; + } + + result.status = MmsStaticBrcbStateStatus::ok; + result.bytes_written = required; + return result; +} + +MmsStaticBrcbStateResult MmsStaticBrcbStateCodec::restore( + MmsStaticBrcbRuntime& runtime, + const std::span source) noexcept { + MmsStaticBrcbStateResult result; + result.required_bytes = source.size(); + if (!runtime.initialized_ || runtime.definition_ == nullptr || + runtime.pending_ == nullptr || runtime.slots_.empty()) { + result.status = MmsStaticBrcbStateStatus::invalid_runtime; + return result; + } + if (source.size() < kHeaderBytes || + !std::equal(kMagic.begin(), kMagic.end(), source.begin()) || + read_u16(source, 8U) != format_version || + read_u16(source, 10U) != static_cast(kHeaderBytes)) { + result.status = MmsStaticBrcbStateStatus::invalid_state; + return result; + } + for (std::size_t index = 41U; index < kHeaderBytes; ++index) { + if (source[index] != 0U) { + result.status = MmsStaticBrcbStateStatus::invalid_state; + return result; + } + } + if (read_u64(source, 12U) != definition_fingerprint(*runtime.definition_)) { + result.status = MmsStaticBrcbStateStatus::definition_mismatch; + return result; + } + + const auto count = static_cast(read_u32(source, 20U)); + const auto restored_next_entry = read_u64(source, 24U); + const auto restored_dropped = read_u64(source, 32U); + const auto restored_sequence = source[40U]; + if (count > runtime.slots_.size()) { + result.status = MmsStaticBrcbStateStatus::capacity_mismatch; + return result; + } + if (restored_next_entry == 0U) { + result.status = MmsStaticBrcbStateStatus::invalid_state; + return result; + } + + std::size_t offset = kHeaderBytes; + std::uint64_t previous_entry_number = 0U; + std::uint8_t last_sequence = 0U; + for (std::size_t logical = 0U; logical < count; ++logical) { + if (offset > source.size() || source.size() - offset < kEntryHeaderBytes) { + result.status = MmsStaticBrcbStateStatus::invalid_state; + return result; + } + const auto pdu_bytes = static_cast(read_u32(source, offset)); + const auto entry_id = source.subspan(offset + 4U, 8U); + const auto sequence = source[offset + 12U]; + const auto overflow = source[offset + 13U]; + if (pdu_bytes == 0U || overflow > 1U || source[offset + 14U] != 0U || + source[offset + 15U] != 0U || + pdu_bytes > runtime.slots_[logical].storage.size()) { + result.status = pdu_bytes > runtime.slots_[logical].storage.size() + ? MmsStaticBrcbStateStatus::capacity_mismatch + : MmsStaticBrcbStateStatus::invalid_state; + return result; + } + offset += kEntryHeaderBytes; + if (offset > source.size() || pdu_bytes > source.size() - offset) { + result.status = MmsStaticBrcbStateStatus::invalid_state; + return result; + } + + const auto entry_number = decode_entry_id(entry_id); + if (entry_number == 0U || + (logical != 0U && entry_number != next_entry_number(previous_entry_number))) { + result.status = MmsStaticBrcbStateStatus::invalid_state; + return result; + } + previous_entry_number = entry_number; + last_sequence = sequence; + offset += pdu_bytes; + } + if (offset != source.size() || + (count != 0U && restored_next_entry != next_entry_number(previous_entry_number)) || + (count != 0U && restored_sequence != last_sequence)) { + result.status = MmsStaticBrcbStateStatus::invalid_state; + return result; + } + + clear_slots(runtime.slots_); + offset = kHeaderBytes; + for (std::size_t logical = 0U; logical < count; ++logical) { + auto& slot = runtime.slots_[logical]; + const auto pdu_bytes = static_cast(read_u32(source, offset)); + std::copy_n( + source.subspan(offset + 4U).begin(), + slot.entry_id.size(), + slot.entry_id.begin()); + slot.sequence_number = source[offset + 12U]; + slot.buffer_overflow = source[offset + 13U] != 0U; + slot.bytes = pdu_bytes; + slot.occupied = true; + offset += kEntryHeaderBytes; + std::copy_n( + source.subspan(offset).begin(), + pdu_bytes, + slot.storage.begin()); + offset += pdu_bytes; + } + + runtime.head_ = 0U; + runtime.count_ = count; + runtime.next_entry_number_ = restored_next_entry; + runtime.dropped_reports_ = restored_dropped; + runtime.sequence_number_ = restored_sequence; + runtime.queue_revision_ = runtime.queue_revision_ == + std::numeric_limits::max() + ? 1U + : runtime.queue_revision_ + 1U; + *runtime.pending_ = {}; + runtime.pending_->revision = 1U; + runtime.enabled_ = false; + + result.status = MmsStaticBrcbStateStatus::ok; + result.bytes_written = source.size(); + return result; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_data_set_table.cpp b/src/mms/static_data_set_table.cpp new file mode 100644 index 000000000..3fa764748 --- /dev/null +++ b/src/mms/static_data_set_table.cpp @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_data_set_table.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] bool valid_name(const std::string_view value) noexcept { + if (value.empty() || value.size() > MmsServiceSpanCodec::maximum_identifier_bytes) { + return false; + } + for (const char ch : value) { + const auto byte = static_cast(ch); + if (byte == 0U || byte > 0x7FU) { + return false; + } + } + return true; +} + +[[nodiscard]] bool equals( + const std::span bytes, + const std::string_view text) noexcept { + if (bytes.size() != text.size()) { + return false; + } + for (std::size_t index = 0U; index < bytes.size(); ++index) { + if (bytes[index] != static_cast( + static_cast(text[index]))) { + return false; + } + } + return true; +} + +[[nodiscard]] bool same_member( + const MmsStaticDataSetMember& left, + const MmsStaticDataSetMember& right) noexcept { + return left.domain == right.domain && left.item == right.item; +} + +} // namespace + +bool MmsStaticDataSetTable::valid() const noexcept { + if (data_sets_.size() > maximum_data_sets) { + return false; + } + for (std::size_t index = 0U; index < data_sets_.size(); ++index) { + const auto& data_set = data_sets_[index]; + if (!valid_name(data_set.domain) || !valid_name(data_set.item) || + data_set.members.empty() || + data_set.members.size() > MmsDataSetSpanCodec::maximum_members) { + return false; + } + for (std::size_t earlier = 0U; earlier < index; ++earlier) { + if (data_sets_[earlier].domain == data_set.domain && + data_sets_[earlier].item == data_set.item) { + return false; + } + } + for (std::size_t member = 0U; member < data_set.members.size(); ++member) { + const auto& current = data_set.members[member]; + if (!valid_name(current.domain) || !valid_name(current.item)) { + return false; + } + for (std::size_t earlier = 0U; earlier < member; ++earlier) { + if (same_member(data_set.members[earlier], current)) { + return false; + } + } + } + } + return true; +} + +bool MmsStaticDataSetTable::valid_against( + const MmsStaticObjectTable& objects) const noexcept { + if (!valid() || !objects.valid()) { + return false; + } + for (const auto& data_set : data_sets_) { + for (const auto& member : data_set.members) { + const MmsObjectNameView name{ + MmsObjectNameViewKind::domain_specific, + std::span{ + reinterpret_cast(member.domain.data()), + member.domain.size()}, + std::span{ + reinterpret_cast(member.item.data()), + member.item.size()}}; + if (objects.find(name) == nullptr) { + return false; + } + } + } + return true; +} + +const MmsStaticDataSetEntry* MmsStaticDataSetTable::find( + const MmsObjectNameView& name) const noexcept { + if (name.kind != MmsObjectNameViewKind::domain_specific) { + return nullptr; + } + for (const auto& data_set : data_sets_) { + if (equals(name.domain, data_set.domain) && equals(name.item, data_set.item)) { + return &data_set; + } + } + return nullptr; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_dispatcher.cpp b/src/mms/static_dispatcher.cpp new file mode 100644 index 000000000..ebc8ace4d --- /dev/null +++ b/src/mms/static_dispatcher.cpp @@ -0,0 +1,414 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_dispatcher.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] bool span_equals( + const std::span bytes, + const std::string_view text) noexcept { + if (bytes.size() != text.size()) { + return false; + } + for (std::size_t index = 0U; index < bytes.size(); ++index) { + if (bytes[index] != static_cast( + static_cast(text[index]))) { + return false; + } + } + return true; +} + +[[nodiscard]] bool valid_mms_data( + const std::span encoded) noexcept { + asn1::BerTlvView data; + if (!asn1::BerSpanReader::try_read_exact(encoded, data) || + data.tag_class != asn1::BerClass::context_specific) { + return false; + } + if (data.tag_number == 1 || data.tag_number == 2) { + return data.constructed; + } + return data.tag_number >= 3 && data.tag_number <= 17 && !data.constructed; +} + +[[nodiscard]] MmsStaticDispatchResult make_status( + const MmsStaticDispatchStatus status, + const MmsConfirmedPduView& request, + const std::size_t required_bytes = 0U) noexcept { + return MmsStaticDispatchResult{ + status, + request.service(), + request.invoke_id, + 0U, + required_bytes}; +} + +[[nodiscard]] MmsStaticDispatchResult make_encoded( + const MmsConfirmedPduView& request, + const wire::EncodeResult encoded) noexcept { + if (encoded.success()) { + return MmsStaticDispatchResult{ + MmsStaticDispatchStatus::response_ready, + request.service(), + request.invoke_id, + encoded.bytes_written, + encoded.required_bytes}; + } + if (encoded.status == wire::EncodeStatus::buffer_too_small) { + return make_status( + MmsStaticDispatchStatus::response_buffer_too_small, + request, + encoded.required_bytes); + } + return make_status(MmsStaticDispatchStatus::backend_failure, request); +} + +[[nodiscard]] bool policy_valid(const MmsStaticDispatchPolicy& policy) noexcept { + return policy.maximum_names_per_response > 0U && + policy.maximum_names_per_response <= MmsServiceSpanCodec::maximum_identifiers && + policy.maximum_write_variables > 0U && + policy.maximum_write_variables <= MmsServiceSpanCodec::maximum_variables; +} + +[[nodiscard]] bool append_unique( + std::array& names, + std::size_t& count, + const std::string_view value) noexcept { + for (std::size_t index = 0U; index < count; ++index) { + if (names[index] == value) { + return true; + } + } + if (count >= names.size()) { + return false; + } + names[count++] = value; + return true; +} + +[[nodiscard]] std::size_t collect_names( + const MmsStaticObjectTable& objects, + const MmsStaticDataSetTable& data_sets, + const MmsGetNameListRequestView& request, + std::array& names) noexcept { + std::size_t count = 0U; + if (request.object_class == MmsNameListObjectClass::domain && + request.scope == MmsNameScopeKind::vmd_specific) { + for (const auto& object : objects.objects()) { + if (!append_unique(names, count, object.domain)) { + return names.size() + 1U; + } + } + for (const auto& data_set : data_sets.data_sets()) { + if (!append_unique(names, count, data_set.domain)) { + return names.size() + 1U; + } + } + return count; + } + + if (request.object_class == MmsNameListObjectClass::named_variable && + request.scope == MmsNameScopeKind::domain_specific) { + for (const auto& object : objects.objects()) { + if (span_equals(request.domain_id, object.domain)) { + if (count >= names.size()) { + return names.size() + 1U; + } + names[count++] = object.item; + } + } + return count; + } + + if (request.object_class == MmsNameListObjectClass::named_variable_list && + request.scope == MmsNameScopeKind::domain_specific) { + for (const auto& data_set : data_sets.data_sets()) { + if (span_equals(request.domain_id, data_set.domain)) { + if (count >= names.size()) { + return names.size() + 1U; + } + names[count++] = data_set.item; + } + } + return count; + } + + return names.size() + 1U; +} + +[[nodiscard]] MmsStaticDispatchResult dispatch_get_name_list( + const MmsStaticObjectTable& objects, + const MmsStaticDataSetTable& data_sets, + const MmsStaticDispatchPolicy& policy, + const MmsConfirmedPduView& confirmed, + const std::span response) noexcept { + MmsGetNameListRequestView request; + if (!MmsServiceSpanCodec::try_decode_get_name_list_request(confirmed, request)) { + return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); + } + + std::array names{}; + const auto name_count = collect_names(objects, data_sets, request, names); + if (name_count > names.size()) { + return make_status(MmsStaticDispatchStatus::unsupported_request, confirmed); + } + + std::size_t start = 0U; + if (!request.continue_after.empty()) { + bool found = false; + for (std::size_t index = 0U; index < name_count; ++index) { + if (span_equals(request.continue_after, names[index])) { + start = index + 1U; + found = true; + break; + } + } + if (!found) { + return make_status(MmsStaticDispatchStatus::object_not_found, confirmed); + } + } + + const auto available = name_count - start; + auto page_count = std::min(available, policy.maximum_names_per_response); + if (available == 0U) { + const std::span empty; + return make_encoded( + confirmed, + MmsServiceSpanCodec::encode_get_name_list_response_into( + confirmed.invoke_id, empty, false, response)); + } + + while (page_count > 0U) { + const auto more_follows = start + page_count < name_count; + const auto encoded = MmsServiceSpanCodec::encode_get_name_list_response_into( + confirmed.invoke_id, + std::span{names}.subspan(start, page_count), + more_follows, + response); + if (encoded.success()) { + return make_encoded(confirmed, encoded); + } + if (encoded.status != wire::EncodeStatus::buffer_too_small || page_count == 1U) { + return make_encoded(confirmed, encoded); + } + --page_count; + } + return make_status(MmsStaticDispatchStatus::backend_failure, confirmed); +} + +[[nodiscard]] MmsStaticDispatchResult dispatch_attributes( + const MmsStaticObjectTable& objects, + const MmsConfirmedPduView& confirmed, + const std::span response) noexcept { + MmsVariableAccessAttributesRequestView request; + if (!MmsServiceSpanCodec::try_decode_variable_access_attributes_request( + confirmed, request)) { + return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); + } + const auto* object = objects.find(request.name); + if (object == nullptr) { + return make_status(MmsStaticDispatchStatus::object_not_found, confirmed); + } + return make_encoded( + confirmed, + MmsServiceSpanCodec::encode_variable_access_attributes_response_into( + confirmed.invoke_id, + object->mms_deletable, + object->type_specification, + response)); +} + +[[nodiscard]] MmsStaticDispatchResult dispatch_data_set_attributes( + const MmsStaticDataSetTable& data_sets, + const MmsConfirmedPduView& confirmed, + const std::span response) noexcept { + MmsNamedVariableListAttributesRequestView request; + if (!MmsDataSetSpanCodec::try_decode_get_named_variable_list_attributes_request( + confirmed, request)) { + return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); + } + const auto* data_set = data_sets.find(request.name); + if (data_set == nullptr) { + return make_status(MmsStaticDispatchStatus::object_not_found, confirmed); + } + + std::array members{}; + for (std::size_t index = 0U; index < data_set->members.size(); ++index) { + members[index] = MmsNamedVariableListMemberInput{ + data_set->members[index].domain, + data_set->members[index].item}; + } + return make_encoded( + confirmed, + MmsDataSetSpanCodec::encode_get_named_variable_list_attributes_response_into( + confirmed.invoke_id, + data_set->mms_deletable, + std::span{members}.first( + data_set->members.size()), + response)); +} + +[[nodiscard]] MmsStaticDispatchResult dispatch_read( + const MmsStaticObjectTable& objects, + const MmsStaticDispatchPolicy& policy, + const MmsConfirmedPduView& confirmed, + const std::span response, + const std::span workspace) noexcept { + MmsReadRequestView request; + if (!MmsServiceSpanCodec::try_decode_read_request(confirmed, request)) { + return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); + } + + std::array results{}; + std::size_t workspace_offset = 0U; + for (std::size_t index = 0U; index < request.variable_count; ++index) { + MmsObjectNameView name; + if (!request.try_variable(index, name)) { + return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); + } + const auto* object = objects.find(name); + if (object == nullptr) { + results[index] = MmsReadAccessResultInput{ + false, {}, policy.missing_object_failure_code}; + continue; + } + + const auto remaining = workspace.subspan(workspace_offset); + const auto read = object->read(object->context, remaining); + if (read.status == wire::EncodeStatus::buffer_too_small) { + return make_status( + MmsStaticDispatchStatus::workspace_too_small, + confirmed, + workspace_offset + read.required_bytes); + } + if (!read.success()) { + results[index] = MmsReadAccessResultInput{ + false, {}, policy.backend_failure_code}; + continue; + } + if (read.bytes_written > remaining.size() || + !valid_mms_data(remaining.first(read.bytes_written))) { + return make_status(MmsStaticDispatchStatus::backend_failure, confirmed); + } + results[index] = MmsReadAccessResultInput{ + true, + remaining.first(read.bytes_written), + 0U}; + workspace_offset += read.bytes_written; + } + + return make_encoded( + confirmed, + MmsServiceSpanCodec::encode_read_response_into( + confirmed.invoke_id, + std::span{results}.first(request.variable_count), + response)); +} + +[[nodiscard]] MmsStaticDispatchResult dispatch_write( + const MmsStaticObjectTable& objects, + const MmsStaticDispatchPolicy& policy, + const MmsConfirmedPduView& confirmed, + const std::span response) noexcept { + MmsWriteRequestView request; + if (!MmsServiceSpanCodec::try_decode_write_request(confirmed, request)) { + return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); + } + if (request.variable_count > policy.maximum_write_variables) { + return make_status(MmsStaticDispatchStatus::unsupported_request, confirmed); + } + + std::array resolved{}; + std::array, MmsServiceSpanCodec::maximum_variables> values{}; + for (std::size_t index = 0U; index < request.variable_count; ++index) { + MmsObjectNameView name; + if (!request.try_variable(index, name) || !request.try_value(index, values[index])) { + return make_status(MmsStaticDispatchStatus::malformed_request, confirmed); + } + resolved[index] = objects.find(name); + } + + std::array results{}; + for (std::size_t index = 0U; index < request.variable_count; ++index) { + const auto* object = resolved[index]; + if (object == nullptr) { + results[index] = MmsWriteAccessResultInput{ + false, policy.missing_object_failure_code}; + continue; + } + if (!object->writable()) { + results[index] = MmsWriteAccessResultInput{ + false, policy.access_denied_failure_code}; + continue; + } + const auto applied = object->write(object->write_context, values[index]); + results[index] = MmsWriteAccessResultInput{ + applied.success, + applied.success ? 0U : applied.failure_code}; + } + + return make_encoded( + confirmed, + MmsServiceSpanCodec::encode_write_response_into( + confirmed.invoke_id, + std::span{results}.first(request.variable_count), + response)); +} + +} // namespace + +MmsStaticDispatchResult MmsStaticApplicationDispatcher::dispatch( + const std::span mms_request, + const std::span response, + const std::span workspace) const noexcept { + MmsConfirmedPduView request; + if (!MmsPduSpanCodec::try_decode_confirmed_request_view(mms_request, request)) { + return MmsStaticDispatchResult{ + MmsStaticDispatchStatus::malformed_request, + MmsWireConfirmedService::unknown, + 0U, + 0U, + 0U}; + } + return dispatch(request, response, workspace); +} + +MmsStaticDispatchResult MmsStaticApplicationDispatcher::dispatch( + const MmsConfirmedPduView& request, + const std::span response, + const std::span workspace) const noexcept { + if (!objects_.valid() || !data_sets_.valid_against(objects_) || !policy_valid(policy_)) { + return make_status(MmsStaticDispatchStatus::invalid_object_table, request); + } + if (request.kind != MmsWirePduKind::confirmed_request) { + return make_status(MmsStaticDispatchStatus::malformed_request, request); + } + + switch (request.service()) { + case MmsWireConfirmedService::get_name_list: + return dispatch_get_name_list(objects_, data_sets_, policy_, request, response); + case MmsWireConfirmedService::get_variable_access_attributes: + return dispatch_attributes(objects_, request, response); + case MmsWireConfirmedService::get_named_variable_list_attributes: + return dispatch_data_set_attributes(data_sets_, request, response); + case MmsWireConfirmedService::read: + return dispatch_read(objects_, policy_, request, response, workspace); + case MmsWireConfirmedService::write: + return dispatch_write(objects_, policy_, request, response); + default: + return make_status(MmsStaticDispatchStatus::unsupported_service, request); + } +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_information_report.cpp b/src/mms/static_information_report.cpp new file mode 100644 index 000000000..42971f45f --- /dev/null +++ b/src/mms/static_information_report.cpp @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_information_report.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] std::span as_bytes( + const std::string_view text) noexcept { + return { + reinterpret_cast(text.data()), + text.size()}; +} + +[[nodiscard]] bool valid_mms_data( + const std::span encoded) noexcept { + asn1::BerTlvView data; + if (!asn1::BerSpanReader::try_read_exact(encoded, data) || + data.tag_class != asn1::BerClass::context_specific) { + return false; + } + if (data.tag_number == 1 || data.tag_number == 2) { + return data.constructed; + } + return data.tag_number >= 3 && data.tag_number <= 17 && !data.constructed; +} + +[[nodiscard]] MmsStaticInformationReportEncodeResult make_status( + const MmsStaticInformationReportStatus status, + const std::size_t required_bytes = 0U, + const std::size_t member_count = 0U) noexcept { + return {status, 0U, required_bytes, member_count}; +} + +} // namespace + +MmsStaticInformationReportEncodeResult +MmsStaticInformationReportEncoder::encode_data_set_snapshot_into( + const MmsStaticObjectTable& objects, + const MmsStaticDataSetTable& data_sets, + const MmsObjectNameView& data_set_name, + const MmsStaticInformationReportInput& report, + const std::span destination, + const std::span workspace) noexcept { + if (!objects.valid()) { + return make_status(MmsStaticInformationReportStatus::invalid_object_table); + } + if (!data_sets.valid()) { + return make_status(MmsStaticInformationReportStatus::invalid_data_set_table); + } + + const auto* data_set = data_sets.find(data_set_name); + if (data_set == nullptr) { + return make_status(MmsStaticInformationReportStatus::data_set_not_found); + } + if (data_set->members.empty() || + data_set->members.size() > MmsInformationReportSpanCodec::maximum_members) { + return make_status(MmsStaticInformationReportStatus::invalid_data_set_table); + } + + std::array references{}; + std::array results{}; + + std::size_t workspace_offset = 0U; + for (std::size_t index = 0U; index < data_set->members.size(); ++index) { + const auto& member = data_set->members[index]; + references[index] = MmsInformationReportReferenceInput{ + member.domain, member.item}; + + const MmsObjectNameView member_name{ + MmsObjectNameViewKind::domain_specific, + as_bytes(member.domain), + as_bytes(member.item)}; + const auto* object = objects.find(member_name); + if (object == nullptr) { + return make_status( + MmsStaticInformationReportStatus::object_not_found, + 0U, + data_set->members.size()); + } + + const auto remaining = workspace.subspan(workspace_offset); + const auto read = object->read(object->context, remaining); + if (read.status == wire::EncodeStatus::buffer_too_small) { + if (read.required_bytes > + std::numeric_limits::max() - workspace_offset) { + return make_status( + MmsStaticInformationReportStatus::backend_failure, + 0U, + data_set->members.size()); + } + return make_status( + MmsStaticInformationReportStatus::workspace_too_small, + workspace_offset + read.required_bytes, + data_set->members.size()); + } + if (!read.success() || read.bytes_written > remaining.size() || + !valid_mms_data(remaining.first(read.bytes_written))) { + return make_status( + MmsStaticInformationReportStatus::backend_failure, + 0U, + data_set->members.size()); + } + + results[index] = MmsReadAccessResultInput{ + true, + remaining.first(read.bytes_written), + 0U}; + workspace_offset += read.bytes_written; + } + + MmsInformationReportSnapshotInput wire_report; + wire_report.report_id = report.report_id; + wire_report.optional_fields = report.optional_fields; + wire_report.sequence_number = report.sequence_number; + wire_report.report_time = report.report_time; + wire_report.data_set_reference = MmsInformationReportReferenceInput{ + data_set->domain, data_set->item}; + wire_report.buffered = report.buffered; + wire_report.buffer_overflow = report.buffer_overflow; + wire_report.entry_id = report.entry_id; + wire_report.conf_revision = report.conf_revision; + wire_report.member_references = + std::span{references}.first( + data_set->members.size()); + wire_report.member_results = + std::span{results}.first( + data_set->members.size()); + wire_report.reason_for_inclusion = report.reason_for_inclusion; + + const auto encoded = MmsInformationReportSpanCodec::encode_snapshot_into( + wire_report, destination); + if (encoded.success()) { + return { + MmsStaticInformationReportStatus::response_ready, + encoded.bytes_written, + encoded.required_bytes, + data_set->members.size()}; + } + if (encoded.status == wire::EncodeStatus::buffer_too_small) { + return make_status( + MmsStaticInformationReportStatus::response_buffer_too_small, + encoded.required_bytes, + data_set->members.size()); + } + return make_status( + MmsStaticInformationReportStatus::invalid_report, + encoded.required_bytes, + data_set->members.size()); +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_object_table.cpp b/src/mms/static_object_table.cpp new file mode 100644 index 000000000..8bff516b0 --- /dev/null +++ b/src/mms/static_object_table.cpp @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_object_table.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] bool visible_ascii(const std::string_view value) noexcept { + if (value.empty() || value.size() > MmsServiceSpanCodec::maximum_identifier_bytes) { + return false; + } + for (const auto character : value) { + const auto byte = static_cast(character); + if (byte == 0U || byte > 0x7FU) { + return false; + } + } + return true; +} + +[[nodiscard]] bool span_equals( + const std::span bytes, + const std::string_view text) noexcept { + if (bytes.size() != text.size()) { + return false; + } + for (std::size_t index = 0U; index < bytes.size(); ++index) { + if (bytes[index] != static_cast( + static_cast(text[index]))) { + return false; + } + } + return true; +} + +[[nodiscard]] bool valid_type_specification( + const std::span encoded) noexcept { + asn1::BerTlvView type; + return !encoded.empty() && + asn1::BerSpanReader::try_read_exact(encoded, type) && + type.tag_class == asn1::BerClass::context_specific; +} + +} // namespace + +bool MmsStaticObjectTable::valid() const noexcept { + if (objects_.empty() || objects_.size() > maximum_objects) { + return false; + } + for (std::size_t index = 0U; index < objects_.size(); ++index) { + const auto& object = objects_[index]; + if (!visible_ascii(object.domain) || !visible_ascii(object.item) || + !valid_type_specification(object.type_specification) || + object.read == nullptr) { + return false; + } + for (std::size_t other = index + 1U; other < objects_.size(); ++other) { + if (objects_[other].domain == object.domain && + objects_[other].item == object.item) { + return false; + } + } + } + return true; +} + +const MmsStaticObjectEntry* MmsStaticObjectTable::find( + const MmsObjectNameView& name) const noexcept { + if (name.kind != MmsObjectNameViewKind::domain_specific || + name.domain.empty() || name.item.empty()) { + return nullptr; + } + for (const auto& object : objects_) { + if (span_equals(name.domain, object.domain) && + span_equals(name.item, object.item)) { + return &object; + } + } + return nullptr; +} + +bool MmsStaticObjectTable::try_resolve_read_request( + const MmsReadRequestView& request, + const std::span resolved, + std::size_t& resolved_count) const noexcept { + resolved_count = 0U; + if (request.variable_count == 0U || + request.variable_count > resolved.size() || + request.variable_count > MmsServiceSpanCodec::maximum_variables) { + return false; + } + for (std::size_t index = 0U; index < request.variable_count; ++index) { + MmsObjectNameView name; + if (!request.try_variable(index, name)) { + resolved_count = 0U; + return false; + } + resolved[index] = find(name); + ++resolved_count; + } + return true; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_report_connection.cpp b/src/mms/static_report_connection.cpp new file mode 100644 index 000000000..b65961490 --- /dev/null +++ b/src/mms/static_report_connection.cpp @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_report_connection.hpp" + +#include "ariec61850/osi/cotp_span.hpp" +#include "ariec61850/osi/presentation_span.hpp" +#include "ariec61850/osi/tpkt_span.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] bool add_size( + const std::size_t base, + const std::size_t extra, + std::size_t& total) noexcept { + if (extra > std::numeric_limits::max() - base) { + total = 0U; + return false; + } + total = base + extra; + return true; +} + +[[nodiscard]] MmsStaticReportConnectionResult make_result( + const MmsStaticReportConnectionStatus status, + const MmsStaticUrcbStatus urcb_status, + const MmsStaticUrcbEmissionPlan& plan = {}) noexcept { + MmsStaticReportConnectionResult result; + result.status = status; + result.urcb_status = urcb_status; + result.control_block_index = plan.index; + result.sequence_number = plan.sequence_number; + result.reason = plan.reason; + return result; +} + +[[nodiscard]] bool final_frame_size( + const std::uint32_t presentation_context_id, + const std::size_t mms_bytes, + std::size_t& required) noexcept { + const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size( + presentation_context_id, mms_bytes); + return fully_encoded && add_size(*fully_encoded, 11U, required); +} + +[[nodiscard]] MmsStaticReportConnectionResult response_capacity( + const MmsStaticUrcbEmissionPlan& plan, + const MmsStaticUrcbStatus urcb_status, + const std::size_t required) noexcept { + auto result = make_result( + MmsStaticReportConnectionStatus::response_buffer_too_small, + urcb_status, + plan); + result.required_response_bytes = required; + return result; +} + +[[nodiscard]] MmsStaticReportConnectionResult workspace_capacity( + const MmsStaticUrcbEmissionPlan& plan, + const MmsStaticUrcbStatus urcb_status, + const std::size_t required) noexcept { + auto result = make_result( + MmsStaticReportConnectionStatus::workspace_too_small, + urcb_status, + plan); + result.required_workspace_bytes = required; + return result; +} + +} // namespace + +MmsStaticReportConnectionResult MmsStaticReportConnection::poll( + const MmsStaticConnectionRuntime& connection, + MmsStaticUrcbRuntime& reports, + const std::uint64_t now_ms, + const std::span report_time, + const std::span response, + const std::span workspace) noexcept { + if (connection.state() != MmsStaticConnectionState::established || + connection.mms_presentation_context_id() == 0U) { + return make_result( + MmsStaticReportConnectionStatus::not_established, + MmsStaticUrcbStatus::temporarily_unavailable); + } + + MmsStaticUrcbEmissionPlan plan; + if (!reports.next_due(now_ms, plan)) { + return make_result( + MmsStaticReportConnectionStatus::no_report_due, + reports.valid() + ? MmsStaticUrcbStatus::no_report_due + : MmsStaticUrcbStatus::invalid_runtime); + } + + // Raw MMS report is written into response. The report encoder may use the + // caller workspace transiently for DataSet member values. Only after the + // raw PDU succeeds is workspace reused for P-DATA and final TPKT staging. + const auto encoded = reports.encode(plan, report_time, response, workspace); + if (!encoded.success()) { + if (encoded.status == MmsStaticUrcbStatus::response_buffer_too_small) { + std::size_t final_required{}; + if (!final_frame_size( + connection.mms_presentation_context_id(), + encoded.required_bytes, + final_required)) { + return make_result( + MmsStaticReportConnectionStatus::report_encode_failed, + MmsStaticUrcbStatus::report_encode_failed, + plan); + } + return response_capacity(plan, encoded.status, final_required); + } + if (encoded.status == MmsStaticUrcbStatus::workspace_too_small) { + return workspace_capacity(plan, encoded.status, encoded.required_bytes); + } + return make_result( + MmsStaticReportConnectionStatus::report_encode_failed, + encoded.status, + plan); + } + + const auto p_data = osi::PresentationSpanCodec::encode_p_data_into( + response.first(encoded.bytes_written), + workspace, + connection.mms_presentation_context_id(), + true); + if (!p_data.success()) { + if (p_data.status == wire::EncodeStatus::buffer_too_small) { + std::size_t final_required{}; + if (!add_size(p_data.required_bytes, 7U, final_required)) { + return make_result( + MmsStaticReportConnectionStatus::report_encode_failed, + MmsStaticUrcbStatus::report_encode_failed, + plan); + } + auto result = workspace_capacity( + plan, MmsStaticUrcbStatus::workspace_too_small, final_required); + result.required_response_bytes = final_required; + return result; + } + return make_result( + MmsStaticReportConnectionStatus::report_encode_failed, + MmsStaticUrcbStatus::report_encode_failed, + plan); + } + + std::size_t final_required{}; + if (!add_size(p_data.bytes_written, 7U, final_required)) { + return make_result( + MmsStaticReportConnectionStatus::report_encode_failed, + MmsStaticUrcbStatus::report_encode_failed, + plan); + } + if (response.size() < final_required) { + return response_capacity( + plan, MmsStaticUrcbStatus::response_buffer_too_small, final_required); + } + if (workspace.size() < final_required) { + auto result = workspace_capacity( + plan, MmsStaticUrcbStatus::workspace_too_small, final_required); + result.required_response_bytes = final_required; + return result; + } + + // P-DATA currently resides in workspace. COTP consumes it into response; + // after that source is no longer needed, so workspace can safely become + // the TPKT staging destination. + const auto cotp = osi::CotpSpanCodec::encode_data_into( + workspace.first(p_data.bytes_written), + response.first(final_required - osi::TpktSpanCodec::header_length)); + if (!cotp.success()) { + return make_result( + MmsStaticReportConnectionStatus::report_encode_failed, + MmsStaticUrcbStatus::report_encode_failed, + plan); + } + + const auto tpkt = osi::TpktSpanCodec::encode_into( + response.first(cotp.bytes_written), + workspace.first(final_required)); + if (!tpkt.success() || tpkt.bytes_written != final_required) { + return make_result( + MmsStaticReportConnectionStatus::report_encode_failed, + MmsStaticUrcbStatus::report_encode_failed, + plan); + } + + // Commit only after the complete TPKT image exists. If capacity failed at + // any earlier stage, the same plan/SqNum can be retried without a gap. + const auto committed = reports.commit(plan, now_ms); + if (committed != MmsStaticUrcbStatus::ok) { + return make_result( + MmsStaticReportConnectionStatus::stale_plan, + committed, + plan); + } + + std::copy_n(workspace.begin(), tpkt.bytes_written, response.begin()); + auto result = make_result( + MmsStaticReportConnectionStatus::response_ready, + MmsStaticUrcbStatus::ok, + plan); + result.bytes_written = tpkt.bytes_written; + result.required_response_bytes = final_required; + result.required_workspace_bytes = final_required; + return result; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_urcb_event_connection.cpp b/src/mms/static_urcb_event_connection.cpp new file mode 100644 index 000000000..0900f0754 --- /dev/null +++ b/src/mms/static_urcb_event_connection.cpp @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_urcb_event_connection.hpp" + +#include "ariec61850/osi/cotp_span.hpp" +#include "ariec61850/osi/presentation_span.hpp" +#include "ariec61850/osi/tpkt_span.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] bool add_size( + const std::size_t base, + const std::size_t extra, + std::size_t& total) noexcept { + if (extra > std::numeric_limits::max() - base) { + total = 0U; + return false; + } + total = base + extra; + return true; +} + +[[nodiscard]] MmsStaticUrcbEventConnectionResult make_result( + const MmsStaticUrcbEventConnectionStatus status, + const MmsStaticUrcbEventStatus event_status, + const MmsStaticUrcbEventPlan& plan = {}) noexcept { + MmsStaticUrcbEventConnectionResult result; + result.status = status; + result.event_status = event_status; + result.control_block_index = plan.index; + result.sequence_number = plan.sequence_number; + return result; +} + +[[nodiscard]] bool final_frame_size( + const std::uint32_t presentation_context_id, + const std::size_t mms_bytes, + std::size_t& required) noexcept { + const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size( + presentation_context_id, mms_bytes); + return fully_encoded && add_size(*fully_encoded, 11U, required); +} + +[[nodiscard]] MmsStaticUrcbEventConnectionResult response_capacity( + const MmsStaticUrcbEventPlan& plan, + const MmsStaticUrcbEventStatus event_status, + const std::size_t required) noexcept { + auto result = make_result( + MmsStaticUrcbEventConnectionStatus::response_buffer_too_small, + event_status, + plan); + result.required_response_bytes = required; + return result; +} + +[[nodiscard]] MmsStaticUrcbEventConnectionResult workspace_capacity( + const MmsStaticUrcbEventPlan& plan, + const MmsStaticUrcbEventStatus event_status, + const std::size_t required) noexcept { + auto result = make_result( + MmsStaticUrcbEventConnectionStatus::workspace_too_small, + event_status, + plan); + result.required_workspace_bytes = required; + return result; +} + +} // namespace + +MmsStaticUrcbEventConnectionResult MmsStaticUrcbEventConnection::poll( + const MmsStaticConnectionRuntime& connection, + MmsStaticUrcbEventRuntime& events, + const std::uint64_t now_ms, + const std::span report_time, + const std::span response, + const std::span workspace) noexcept { + if (connection.state() != MmsStaticConnectionState::established || + connection.mms_presentation_context_id() == 0U) { + return make_result( + MmsStaticUrcbEventConnectionStatus::not_established, + MmsStaticUrcbEventStatus::temporarily_unavailable); + } + + MmsStaticUrcbEventPlan plan; + if (!events.next_due(now_ms, plan)) { + return make_result( + MmsStaticUrcbEventConnectionStatus::no_report_due, + events.valid() + ? MmsStaticUrcbEventStatus::ok + : MmsStaticUrcbEventStatus::invalid_runtime); + } + + const auto encoded = events.encode(plan, report_time, response, workspace); + if (!encoded.success()) { + if (encoded.status == MmsStaticUrcbEventStatus::response_buffer_too_small) { + std::size_t final_required{}; + if (!final_frame_size( + connection.mms_presentation_context_id(), + encoded.required_bytes, + final_required)) { + return make_result( + MmsStaticUrcbEventConnectionStatus::report_encode_failed, + MmsStaticUrcbEventStatus::report_encode_failed, + plan); + } + return response_capacity(plan, encoded.status, final_required); + } + if (encoded.status == MmsStaticUrcbEventStatus::workspace_too_small) { + return workspace_capacity(plan, encoded.status, encoded.required_bytes); + } + if (encoded.status == MmsStaticUrcbEventStatus::stale_plan) { + return make_result( + MmsStaticUrcbEventConnectionStatus::stale_plan, + encoded.status, + plan); + } + return make_result( + MmsStaticUrcbEventConnectionStatus::report_encode_failed, + encoded.status, + plan); + } + + const auto p_data = osi::PresentationSpanCodec::encode_p_data_into( + response.first(encoded.bytes_written), + workspace, + connection.mms_presentation_context_id(), + true); + if (!p_data.success()) { + if (p_data.status == wire::EncodeStatus::buffer_too_small) { + std::size_t final_required{}; + if (!add_size(p_data.required_bytes, 7U, final_required)) { + return make_result( + MmsStaticUrcbEventConnectionStatus::report_encode_failed, + MmsStaticUrcbEventStatus::report_encode_failed, + plan); + } + auto result = workspace_capacity( + plan, MmsStaticUrcbEventStatus::workspace_too_small, final_required); + result.required_response_bytes = final_required; + return result; + } + return make_result( + MmsStaticUrcbEventConnectionStatus::report_encode_failed, + MmsStaticUrcbEventStatus::report_encode_failed, + plan); + } + + std::size_t final_required{}; + if (!add_size(p_data.bytes_written, 7U, final_required)) { + return make_result( + MmsStaticUrcbEventConnectionStatus::report_encode_failed, + MmsStaticUrcbEventStatus::report_encode_failed, + plan); + } + if (response.size() < final_required) { + return response_capacity( + plan, MmsStaticUrcbEventStatus::response_buffer_too_small, final_required); + } + if (workspace.size() < final_required) { + auto result = workspace_capacity( + plan, MmsStaticUrcbEventStatus::workspace_too_small, final_required); + result.required_response_bytes = final_required; + return result; + } + + const auto cotp = osi::CotpSpanCodec::encode_data_into( + workspace.first(p_data.bytes_written), + response.first(final_required - osi::TpktSpanCodec::header_length)); + if (!cotp.success()) { + return make_result( + MmsStaticUrcbEventConnectionStatus::report_encode_failed, + MmsStaticUrcbEventStatus::report_encode_failed, + plan); + } + + const auto tpkt = osi::TpktSpanCodec::encode_into( + response.first(cotp.bytes_written), + workspace.first(final_required)); + if (!tpkt.success() || tpkt.bytes_written != final_required) { + return make_result( + MmsStaticUrcbEventConnectionStatus::report_encode_failed, + MmsStaticUrcbEventStatus::report_encode_failed, + plan); + } + + const auto committed = events.commit(plan); + if (committed != MmsStaticUrcbEventStatus::ok) { + return make_result( + MmsStaticUrcbEventConnectionStatus::stale_plan, + committed, + plan); + } + + std::copy_n(workspace.begin(), tpkt.bytes_written, response.begin()); + auto result = make_result( + MmsStaticUrcbEventConnectionStatus::response_ready, + MmsStaticUrcbEventStatus::ok, + plan); + result.bytes_written = tpkt.bytes_written; + result.required_response_bytes = final_required; + result.required_workspace_bytes = final_required; + return result; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_urcb_event_runtime.cpp b/src/mms/static_urcb_event_runtime.cpp new file mode 100644 index 000000000..0a8c7396b --- /dev/null +++ b/src/mms/static_urcb_event_runtime.cpp @@ -0,0 +1,391 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_urcb_event_runtime.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +constexpr std::uint8_t kTriggerDataChange = 0x40U; +constexpr std::uint8_t kTriggerQualityChange = 0x20U; +constexpr std::uint8_t kTriggerDataUpdate = 0x10U; + +constexpr std::uint8_t kReasonDataChange = 0x80U; +constexpr std::uint8_t kReasonQualityChange = 0x40U; +constexpr std::uint8_t kReasonDataUpdate = 0x20U; +constexpr std::uint8_t kOptReasonForInclusion = 0x10U; + +[[nodiscard]] std::uint64_t saturating_add( + const std::uint64_t base, + const std::uint64_t delta) noexcept { + return delta > std::numeric_limits::max() - base + ? std::numeric_limits::max() + : base + delta; +} + +void bump_revision(std::uint32_t& revision) noexcept { + revision = revision == std::numeric_limits::max() + ? 1U + : revision + 1U; +} + +[[nodiscard]] std::uint8_t next_sequence_number( + const std::uint8_t current) noexcept { + return static_cast( + (static_cast(current) + 1U) & 0xFFU); +} + +[[nodiscard]] std::span as_bytes( + const std::string_view text) noexcept { + return { + reinterpret_cast(text.data()), + text.size()}; +} + +[[nodiscard]] MmsObjectNameView object_name( + const std::string_view domain, + const std::string_view item) noexcept { + return { + MmsObjectNameViewKind::domain_specific, + as_bytes(domain), + as_bytes(item)}; +} + +void clear_pending(MmsStaticUrcbEventState& state) noexcept { + std::fill(state.member_reason_masks.begin(), state.member_reason_masks.end(), 0U); + state.due_ms = 0U; + state.source_revision = 0U; + state.pending_member_count = 0U; + state.pending = false; +} + +[[nodiscard]] bool event_mapping( + const MmsStaticUrcbEventReason reason, + std::uint8_t& trigger_mask, + std::uint8_t& report_reason) noexcept { + switch (reason) { + case MmsStaticUrcbEventReason::data_change: + trigger_mask = kTriggerDataChange; + report_reason = kReasonDataChange; + return true; + case MmsStaticUrcbEventReason::quality_change: + trigger_mask = kTriggerQualityChange; + report_reason = kReasonQualityChange; + return true; + case MmsStaticUrcbEventReason::data_update: + trigger_mask = kTriggerDataUpdate; + report_reason = kReasonDataUpdate; + return true; + } + return false; +} + +[[nodiscard]] bool valid_mms_data( + const std::span encoded) noexcept { + asn1::BerTlvView data; + if (!asn1::BerSpanReader::try_read_exact(encoded, data) || + data.tag_class != asn1::BerClass::context_specific) { + return false; + } + if (data.tag_number == 1 || data.tag_number == 2) { + return data.constructed; + } + return data.tag_number >= 3 && data.tag_number <= 17 && !data.constructed; +} + +[[nodiscard]] bool plan_matches( + const MmsStaticUrcbEventPlan& plan, + const MmsStaticUrcbState& control, + const MmsStaticUrcbEventState& event) noexcept { + return control.enabled && event.pending && + event.source_revision == control.revision && + plan.source_revision == control.revision && + plan.event_revision == event.revision && + plan.sequence_number == next_sequence_number(control.sequence_number); +} + +} // namespace + +bool MmsStaticUrcbEventRuntime::initialize() noexcept { + initialized_ = false; + if (urcb_ == nullptr || objects_ == nullptr || data_sets_ == nullptr || + !urcb_->valid() || !objects_->valid() || !data_sets_->valid() || + !data_sets_->valid_against(*objects_) || states_.size() < urcb_->size()) { + return false; + } + for (std::size_t index = 0U; index < urcb_->size(); ++index) { + states_[index] = {}; + states_[index].revision = 1U; + } + initialized_ = true; + return true; +} + +const MmsStaticUrcbEventState* MmsStaticUrcbEventRuntime::state( + const std::size_t index) const noexcept { + return initialized_ && urcb_ != nullptr && index < urcb_->size() + ? &states_[index] + : nullptr; +} + +MmsStaticUrcbEventStatus MmsStaticUrcbEventRuntime::notify( + const std::size_t control_block_index, + const std::size_t data_set_member_index, + const MmsStaticUrcbEventReason reason, + const std::uint64_t now_ms) noexcept { + if (!initialized_ || urcb_ == nullptr || data_sets_ == nullptr) { + return MmsStaticUrcbEventStatus::invalid_runtime; + } + auto* control = urcb_->state(control_block_index); + if (control == nullptr || control_block_index >= states_.size()) { + return MmsStaticUrcbEventStatus::index_out_of_range; + } + if (!control->enabled) { + return MmsStaticUrcbEventStatus::temporarily_unavailable; + } + + std::uint8_t trigger_mask = 0U; + std::uint8_t report_reason = 0U; + if (!event_mapping(reason, trigger_mask, report_reason)) { + return MmsStaticUrcbEventStatus::trigger_not_selected; + } + if ((control->trigger_options & trigger_mask) == 0U) { + return MmsStaticUrcbEventStatus::trigger_not_selected; + } + + const auto* data_set = data_sets_->find(object_name( + control->data_set_domain(), control->data_set_item())); + if (data_set == nullptr) { + return MmsStaticUrcbEventStatus::data_set_not_found; + } + if (data_set_member_index >= data_set->members.size() || + data_set_member_index >= MmsInformationReportSpanCodec::maximum_members) { + return MmsStaticUrcbEventStatus::member_out_of_range; + } + + auto& event = states_[control_block_index]; + if (event.pending && event.source_revision != control->revision) { + clear_pending(event); + bump_revision(event.revision); + } + if (!event.pending) { + event.pending = true; + event.source_revision = control->revision; + event.due_ms = saturating_add(now_ms, control->buffer_time_ms); + } + if (event.member_reason_masks[data_set_member_index] == 0U) { + ++event.pending_member_count; + } + event.member_reason_masks[data_set_member_index] = static_cast( + event.member_reason_masks[data_set_member_index] | report_reason); + bump_revision(event.revision); + return MmsStaticUrcbEventStatus::ok; +} + +bool MmsStaticUrcbEventRuntime::next_due( + const std::uint64_t now_ms, + MmsStaticUrcbEventPlan& plan) const noexcept { + plan = {}; + if (!initialized_ || urcb_ == nullptr) { + return false; + } + for (std::size_t index = 0U; index < urcb_->size(); ++index) { + const auto* control = urcb_->state(index); + const auto& event = states_[index]; + if (control == nullptr || !control->enabled || !event.pending || + event.pending_member_count == 0U || + event.source_revision != control->revision || now_ms < event.due_ms) { + continue; + } + plan.index = index; + plan.source_revision = control->revision; + plan.event_revision = event.revision; + plan.sequence_number = next_sequence_number(control->sequence_number); + return true; + } + return false; +} + +MmsStaticUrcbEventEncodeResult MmsStaticUrcbEventRuntime::encode( + const MmsStaticUrcbEventPlan& plan, + const std::span report_time, + const std::span destination, + const std::span workspace) const noexcept { + if (!initialized_ || urcb_ == nullptr || objects_ == nullptr || data_sets_ == nullptr) { + return {MmsStaticUrcbEventStatus::invalid_runtime, 0U, 0U, 0U, 0U}; + } + if (plan.index >= urcb_->size() || plan.index >= states_.size()) { + return {MmsStaticUrcbEventStatus::index_out_of_range, 0U, 0U, 0U, 0U}; + } + const auto* control = urcb_->state(plan.index); + const auto& event = states_[plan.index]; + if (control == nullptr || !plan_matches(plan, *control, event)) { + return {MmsStaticUrcbEventStatus::stale_plan, 0U, 0U, 0U, 0U}; + } + + const auto* data_set = data_sets_->find(object_name( + control->data_set_domain(), control->data_set_item())); + if (data_set == nullptr) { + return {MmsStaticUrcbEventStatus::data_set_not_found, 0U, 0U, 0U, 0U}; + } + if (data_set->members.size() > MmsInformationReportSpanCodec::maximum_members) { + return { + MmsStaticUrcbEventStatus::report_encode_failed, + 0U, + 0U, + data_set->members.size(), + 0U}; + } + + std::array indices{}; + std::array references{}; + std::array results{}; + std::array reasons{}; + + std::size_t included = 0U; + std::size_t workspace_offset = 0U; + for (std::size_t member_index = 0U; + member_index < data_set->members.size(); + ++member_index) { + const auto reason = event.member_reason_masks[member_index]; + if (reason == 0U) { + continue; + } + const auto& member = data_set->members[member_index]; + const auto* object = objects_->find(object_name(member.domain, member.item)); + if (object == nullptr) { + return { + MmsStaticUrcbEventStatus::object_not_found, + 0U, + 0U, + data_set->members.size(), + included}; + } + const auto remaining = workspace.subspan(workspace_offset); + const auto read = object->read(object->context, remaining); + if (read.status == wire::EncodeStatus::buffer_too_small) { + if (read.required_bytes > + std::numeric_limits::max() - workspace_offset) { + return { + MmsStaticUrcbEventStatus::backend_failure, + 0U, + 0U, + data_set->members.size(), + included}; + } + return { + MmsStaticUrcbEventStatus::workspace_too_small, + 0U, + workspace_offset + read.required_bytes, + data_set->members.size(), + included}; + } + if (!read.success() || read.bytes_written > remaining.size() || + !valid_mms_data(remaining.first(read.bytes_written))) { + return { + MmsStaticUrcbEventStatus::backend_failure, + 0U, + 0U, + data_set->members.size(), + included}; + } + + indices[included] = member_index; + references[included] = {member.domain, member.item}; + results[included] = { + true, + remaining.first(read.bytes_written), + 0U}; + reasons[included] = reason; + workspace_offset += read.bytes_written; + ++included; + } + if (included == 0U || included != event.pending_member_count) { + return { + MmsStaticUrcbEventStatus::stale_plan, + 0U, + 0U, + data_set->members.size(), + included}; + } + + MmsSelectiveInformationReportSnapshotInput report; + report.report_id = control->report_id(); + report.optional_fields = control->optional_fields; + report.sequence_number = plan.sequence_number; + report.report_time = report_time; + report.data_set_reference = { + control->data_set_domain(), control->data_set_item()}; + report.conf_revision = control->conf_revision; + report.data_set_member_count = data_set->members.size(); + report.included_member_indices = + std::span{indices}.first(included); + report.included_member_references = + std::span{references}.first(included); + report.included_member_results = + std::span{results}.first(included); + if (control->optional_fields.size() > 0U && + (control->optional_fields[0] & kOptReasonForInclusion) != 0U) { + report.included_reason_for_inclusion = + std::span{reasons}.first(included); + } + + const auto encoded = MmsSelectiveInformationReportSpanCodec::encode_snapshot_into( + report, destination); + if (encoded.success()) { + return { + MmsStaticUrcbEventStatus::ok, + encoded.bytes_written, + encoded.required_bytes, + data_set->members.size(), + included}; + } + if (encoded.status == wire::EncodeStatus::buffer_too_small) { + return { + MmsStaticUrcbEventStatus::response_buffer_too_small, + 0U, + encoded.required_bytes, + data_set->members.size(), + included}; + } + return { + MmsStaticUrcbEventStatus::report_encode_failed, + 0U, + encoded.required_bytes, + data_set->members.size(), + included}; +} + +MmsStaticUrcbEventStatus MmsStaticUrcbEventRuntime::commit( + const MmsStaticUrcbEventPlan& plan) noexcept { + if (!initialized_ || urcb_ == nullptr) { + return MmsStaticUrcbEventStatus::invalid_runtime; + } + if (plan.index >= urcb_->size() || plan.index >= states_.size()) { + return MmsStaticUrcbEventStatus::index_out_of_range; + } + auto* control = urcb_->state(plan.index); + auto& event = states_[plan.index]; + if (control == nullptr || !plan_matches(plan, *control, event)) { + return MmsStaticUrcbEventStatus::stale_plan; + } + + control->sequence_number = plan.sequence_number; + bump_revision(control->revision); + clear_pending(event); + bump_revision(event.revision); + return MmsStaticUrcbEventStatus::ok; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_urcb_objects.cpp b/src/mms/static_urcb_objects.cpp new file mode 100644 index 000000000..0f5d4f733 --- /dev/null +++ b/src/mms/static_urcb_objects.cpp @@ -0,0 +1,630 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_urcb_objects.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/asn1/ber_span_writer.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kUnsigned32Type{0x86U, 0x01U, 0x20U}; +constexpr std::array kVisible255Type{0x8AU, 0x02U, 0x00U, 0xFFU}; +constexpr std::array kBitString10Type{0x84U, 0x01U, 0x0AU}; +constexpr std::array kBitString6Type{0x84U, 0x01U, 0x06U}; + +constexpr std::array + kAttributes{ + MmsStaticUrcbAttribute::report_id, + MmsStaticUrcbAttribute::report_enabled, + MmsStaticUrcbAttribute::reserved, + MmsStaticUrcbAttribute::data_set, + MmsStaticUrcbAttribute::conf_revision, + MmsStaticUrcbAttribute::optional_fields, + MmsStaticUrcbAttribute::buffer_time, + MmsStaticUrcbAttribute::trigger_options, + MmsStaticUrcbAttribute::integrity_period, + MmsStaticUrcbAttribute::general_interrogation, + MmsStaticUrcbAttribute::sequence_number}; + +constexpr std::array + kSuffixes{ + "RptID", + "RptEna", + "Resv", + "DatSet", + "ConfRev", + "OptFlds", + "BufTm", + "TrgOps", + "IntgPd", + "GI", + "SqNum"}; + +constexpr std::uint32_t kHardwareFault = 1U; +constexpr std::uint32_t kTemporarilyUnavailable = 2U; +constexpr std::uint32_t kObjectAccessDenied = 3U; +constexpr std::uint32_t kTypeInconsistent = 7U; +constexpr std::uint32_t kObjectNonExistent = 10U; +constexpr std::uint32_t kObjectValueInvalid = 11U; + +[[nodiscard]] std::span as_bytes( + const std::string_view text) noexcept { + return { + reinterpret_cast(text.data()), + text.size()}; +} + +[[nodiscard]] bool visible_ascii(const std::span bytes) noexcept { + if (bytes.empty() || bytes.size() > MmsServiceSpanCodec::maximum_identifier_bytes) { + return false; + } + for (const auto byte : bytes) { + if (byte == 0U || byte > 0x7FU) { + return false; + } + } + return true; +} + +[[nodiscard]] std::span type_for( + const MmsStaticUrcbAttribute attribute) noexcept { + switch (attribute) { + case MmsStaticUrcbAttribute::report_id: + case MmsStaticUrcbAttribute::data_set: + return kVisible255Type; + case MmsStaticUrcbAttribute::report_enabled: + case MmsStaticUrcbAttribute::reserved: + case MmsStaticUrcbAttribute::general_interrogation: + return kBooleanType; + case MmsStaticUrcbAttribute::conf_revision: + case MmsStaticUrcbAttribute::buffer_time: + case MmsStaticUrcbAttribute::integrity_period: + case MmsStaticUrcbAttribute::sequence_number: + return kUnsigned32Type; + case MmsStaticUrcbAttribute::optional_fields: + return kBitString10Type; + case MmsStaticUrcbAttribute::trigger_options: + return kBitString6Type; + } + return {}; +} + +[[nodiscard]] bool writable_attribute( + const MmsStaticUrcbAttribute attribute) noexcept { + return attribute != MmsStaticUrcbAttribute::conf_revision && + attribute != MmsStaticUrcbAttribute::sequence_number; +} + +[[nodiscard]] wire::EncodeResult capacity_result( + const std::size_t required, + const std::span destination) noexcept { + return destination.size() < required + ? wire::EncodeResult{wire::EncodeStatus::buffer_too_small, 0U, required} + : wire::EncodeResult{wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] wire::EncodeResult encode_boolean( + const bool value, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + const auto capacity = capacity_result(required, destination); + if (!capacity.success()) { + return capacity; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = value ? 0xFFU : 0x00U; + return capacity; +} + +[[nodiscard]] std::size_t unsigned_content_size(std::uint32_t value) noexcept { + std::size_t size = 1U; + while (value > 0xFFU) { + ++size; + value >>= 8U; + } + return size; +} + +[[nodiscard]] wire::EncodeResult encode_unsigned( + const std::uint32_t value, + const std::span destination) noexcept { + const auto content_size = unsigned_content_size(value); + const auto total = asn1::BerSpanWriter::tlv_size(6, content_size); + if (!total) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto capacity = capacity_result(*total, destination); + if (!capacity.success()) { + return capacity; + } + + asn1::BerSpanWriter writer{destination.first(*total)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 6, content_size)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *total}; + } + for (std::size_t index = content_size; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte(static_cast((value >> shift) & 0xFFU))) { + return {wire::EncodeStatus::value_out_of_range, 0U, *total}; + } + } + return writer.size() == *total + ? capacity + : wire::EncodeResult{wire::EncodeStatus::value_out_of_range, 0U, *total}; +} + +[[nodiscard]] wire::EncodeResult encode_visible( + const std::string_view text, + const std::span destination) noexcept { + const auto total = asn1::BerSpanWriter::tlv_size(10, text.size()); + if (!total) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto capacity = capacity_result(*total, destination); + if (!capacity.success()) { + return capacity; + } + asn1::BerSpanWriter writer{destination.first(*total)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 10, text.size()) || + !writer.write_bytes(as_bytes(text)) || writer.size() != *total) { + return {wire::EncodeStatus::value_out_of_range, 0U, *total}; + } + return capacity; +} + +[[nodiscard]] wire::EncodeResult encode_data_set_reference( + const std::string_view domain, + const std::string_view item, + const std::span destination) noexcept { + if (domain.size() > std::numeric_limits::max() - 1U - item.size()) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto content_size = domain.size() + 1U + item.size(); + const auto total = asn1::BerSpanWriter::tlv_size(10, content_size); + if (!total) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto capacity = capacity_result(*total, destination); + if (!capacity.success()) { + return capacity; + } + + asn1::BerSpanWriter writer{destination.first(*total)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 10, content_size) || + !writer.write_bytes(as_bytes(domain)) || + !writer.write_byte(static_cast('/')) || + !writer.write_bytes(as_bytes(item)) || writer.size() != *total) { + return {wire::EncodeStatus::value_out_of_range, 0U, *total}; + } + return capacity; +} + +[[nodiscard]] wire::EncodeResult encode_bit_string( + const std::uint8_t unused_bits, + const std::span bytes, + const std::span destination) noexcept { + if (unused_bits > 7U || bytes.size() == std::numeric_limits::max()) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto content_size = bytes.size() + 1U; + const auto total = asn1::BerSpanWriter::tlv_size(4, content_size); + if (!total) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto capacity = capacity_result(*total, destination); + if (!capacity.success()) { + return capacity; + } + asn1::BerSpanWriter writer{destination.first(*total)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 4, content_size) || + !writer.write_byte(unused_bits) || !writer.write_bytes(bytes) || + writer.size() != *total) { + return {wire::EncodeStatus::value_out_of_range, 0U, *total}; + } + return capacity; +} + +[[nodiscard]] wire::EncodeResult read_urcb_attribute( + const void* raw_context, + const std::span destination) noexcept { + const auto* context = static_cast(raw_context); + if (context == nullptr || context->runtime == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto* state = context->runtime->state(context->index); + if (state == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + switch (context->attribute) { + case MmsStaticUrcbAttribute::report_id: + return encode_visible(state->report_id(), destination); + case MmsStaticUrcbAttribute::report_enabled: + return encode_boolean(state->enabled, destination); + case MmsStaticUrcbAttribute::reserved: + return encode_boolean(state->reserved, destination); + case MmsStaticUrcbAttribute::data_set: + return encode_data_set_reference( + state->data_set_domain(), state->data_set_item(), destination); + case MmsStaticUrcbAttribute::conf_revision: + return encode_unsigned(state->conf_revision, destination); + case MmsStaticUrcbAttribute::optional_fields: + return encode_bit_string(6U, state->optional_fields, destination); + case MmsStaticUrcbAttribute::buffer_time: + return encode_unsigned(state->buffer_time_ms, destination); + case MmsStaticUrcbAttribute::trigger_options: { + const std::array trigger{state->trigger_options}; + return encode_bit_string(2U, trigger, destination); + } + case MmsStaticUrcbAttribute::integrity_period: + return encode_unsigned(state->integrity_period_ms, destination); + case MmsStaticUrcbAttribute::general_interrogation: + return encode_boolean(state->general_interrogation_pending, destination); + case MmsStaticUrcbAttribute::sequence_number: + return encode_unsigned(state->sequence_number, destination); + } + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; +} + +[[nodiscard]] bool decode_boolean( + const std::span encoded, + bool& value) noexcept { + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(encoded, tlv) || + tlv.tag_class != asn1::BerClass::context_specific || + tlv.tag_number != 3 || tlv.constructed || tlv.value.size() != 1U) { + return false; + } + value = tlv.value[0] != 0U; + return true; +} + +[[nodiscard]] bool decode_unsigned( + const std::span encoded, + std::uint32_t& value) noexcept { + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(encoded, tlv) || + tlv.tag_class != asn1::BerClass::context_specific || + tlv.tag_number != 6 || tlv.constructed) { + return false; + } + const auto decoded = asn1::BerSpanReader::read_uint32(tlv); + if (!decoded) { + return false; + } + value = *decoded; + return true; +} + +[[nodiscard]] bool decode_visible( + const std::span encoded, + std::string_view& value) noexcept { + value = {}; + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(encoded, tlv) || + tlv.tag_class != asn1::BerClass::context_specific || + tlv.tag_number != 10 || tlv.constructed || !visible_ascii(tlv.value)) { + return false; + } + value = { + reinterpret_cast(tlv.value.data()), + tlv.value.size()}; + return true; +} + +[[nodiscard]] bool decode_bit_string( + const std::span encoded, + const std::uint8_t expected_unused_bits, + const std::size_t expected_bytes, + std::span& bytes) noexcept { + bytes = {}; + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(encoded, tlv) || + tlv.tag_class != asn1::BerClass::context_specific || + tlv.tag_number != 4 || tlv.constructed || + tlv.value.size() != expected_bytes + 1U || + tlv.value[0] != expected_unused_bits) { + return false; + } + bytes = tlv.value.subspan(1U); + if (!bytes.empty() && expected_unused_bits != 0U) { + const auto mask = static_cast((1U << expected_unused_bits) - 1U); + if ((bytes.back() & mask) != 0U) { + bytes = {}; + return false; + } + } + return true; +} + +[[nodiscard]] bool split_reference( + const std::string_view reference, + std::string_view& domain, + std::string_view& item) noexcept { + domain = {}; + item = {}; + std::size_t slash = reference.size(); + for (std::size_t index = 0U; index < reference.size(); ++index) { + if (reference[index] != '/') { + continue; + } + if (slash != reference.size()) { + return false; + } + slash = index; + } + if (slash == 0U || slash >= reference.size() - 1U) { + return false; + } + domain = reference.substr(0U, slash); + item = reference.substr(slash + 1U); + return true; +} + +[[nodiscard]] MmsStaticWriteResult map_write_status( + const MmsStaticUrcbStatus status) noexcept { + switch (status) { + case MmsStaticUrcbStatus::ok: + return {true, 0U}; + case MmsStaticUrcbStatus::object_access_denied: + return {false, kObjectAccessDenied}; + case MmsStaticUrcbStatus::temporarily_unavailable: + return {false, kTemporarilyUnavailable}; + case MmsStaticUrcbStatus::data_set_not_found: + return {false, kObjectNonExistent}; + case MmsStaticUrcbStatus::invalid_value: + return {false, kObjectValueInvalid}; + case MmsStaticUrcbStatus::invalid_runtime: + case MmsStaticUrcbStatus::index_out_of_range: + case MmsStaticUrcbStatus::no_report_due: + case MmsStaticUrcbStatus::stale_plan: + case MmsStaticUrcbStatus::workspace_too_small: + case MmsStaticUrcbStatus::backend_failure: + case MmsStaticUrcbStatus::response_buffer_too_small: + case MmsStaticUrcbStatus::report_encode_failed: + return {false, kHardwareFault}; + } + return {false, kHardwareFault}; +} + +[[nodiscard]] MmsStaticWriteResult write_urcb_attribute( + void* raw_context, + const std::span encoded_data) noexcept { + auto* context = static_cast(raw_context); + if (context == nullptr || context->runtime == nullptr) { + return {false, kHardwareFault}; + } + + switch (context->attribute) { + case MmsStaticUrcbAttribute::report_id: { + std::string_view value; + if (!decode_visible(encoded_data, value)) { + return {false, kTypeInconsistent}; + } + return map_write_status(context->runtime->set_report_id(context->index, value)); + } + case MmsStaticUrcbAttribute::report_enabled: { + bool value = false; + if (!decode_boolean(encoded_data, value)) { + return {false, kTypeInconsistent}; + } + const auto now = context->now_ms == nullptr + ? std::uint64_t{0U} + : context->now_ms(context->now_context); + return map_write_status( + context->runtime->set_enabled(context->index, value, now)); + } + case MmsStaticUrcbAttribute::reserved: { + bool value = false; + if (!decode_boolean(encoded_data, value)) { + return {false, kTypeInconsistent}; + } + return map_write_status( + context->runtime->set_reserved(context->index, value)); + } + case MmsStaticUrcbAttribute::data_set: { + std::string_view reference; + std::string_view domain; + std::string_view item; + if (!decode_visible(encoded_data, reference) || + !split_reference(reference, domain, item)) { + return {false, kTypeInconsistent}; + } + return map_write_status( + context->runtime->set_data_set(context->index, domain, item)); + } + case MmsStaticUrcbAttribute::optional_fields: { + std::span bytes; + if (!decode_bit_string(encoded_data, 6U, 2U, bytes)) { + return {false, kTypeInconsistent}; + } + return map_write_status( + context->runtime->set_optional_fields(context->index, bytes)); + } + case MmsStaticUrcbAttribute::buffer_time: { + std::uint32_t value = 0U; + if (!decode_unsigned(encoded_data, value)) { + return {false, kTypeInconsistent}; + } + return map_write_status( + context->runtime->set_buffer_time_ms(context->index, value)); + } + case MmsStaticUrcbAttribute::trigger_options: { + std::span bytes; + if (!decode_bit_string(encoded_data, 2U, 1U, bytes)) { + return {false, kTypeInconsistent}; + } + return map_write_status( + context->runtime->set_trigger_options(context->index, bytes[0])); + } + case MmsStaticUrcbAttribute::integrity_period: { + std::uint32_t value = 0U; + if (!decode_unsigned(encoded_data, value)) { + return {false, kTypeInconsistent}; + } + return map_write_status( + context->runtime->set_integrity_period_ms(context->index, value)); + } + case MmsStaticUrcbAttribute::general_interrogation: { + bool value = false; + if (!decode_boolean(encoded_data, value)) { + return {false, kTypeInconsistent}; + } + return value + ? map_write_status( + context->runtime->request_general_interrogation(context->index)) + : MmsStaticWriteResult{true, 0U}; + } + case MmsStaticUrcbAttribute::conf_revision: + case MmsStaticUrcbAttribute::sequence_number: + return {false, kObjectAccessDenied}; + } + return {false, kHardwareFault}; +} + +} // namespace + +std::size_t MmsStaticUrcbObjectBank::required_context_capacity() const noexcept { + if (runtime_ == nullptr || !runtime_->valid() || + runtime_->size() > std::numeric_limits::max() / + attributes_per_control_block) { + return std::numeric_limits::max(); + } + return runtime_->size() * attributes_per_control_block; +} + +std::size_t MmsStaticUrcbObjectBank::required_object_capacity() const noexcept { + const auto contexts = required_context_capacity(); + if (contexts == std::numeric_limits::max() || + contexts > std::numeric_limits::max() - base_objects_.size()) { + return std::numeric_limits::max(); + } + return base_objects_.size() + contexts; +} + +std::size_t MmsStaticUrcbObjectBank::required_name_bytes() const noexcept { + if (runtime_ == nullptr || !runtime_->valid()) { + return std::numeric_limits::max(); + } + std::size_t total = 0U; + for (std::size_t index = 0U; index < runtime_->size(); ++index) { + const auto* definition = runtime_->definition(index); + if (definition == nullptr) { + return std::numeric_limits::max(); + } + for (const auto suffix : kSuffixes) { + if (definition->item.size() > + MmsServiceSpanCodec::maximum_identifier_bytes - 1U - suffix.size()) { + return std::numeric_limits::max(); + } + const auto name_size = definition->item.size() + 1U + suffix.size(); + if (name_size > std::numeric_limits::max() - total) { + return std::numeric_limits::max(); + } + total += name_size; + } + } + return total; +} + +bool MmsStaticUrcbObjectBank::initialize() noexcept { + initialized_ = false; + object_count_ = 0U; + name_bytes_used_ = 0U; + table_ = MmsStaticObjectTable{std::span{}}; + + if (runtime_ == nullptr || !runtime_->valid()) { + return false; + } + const auto required_objects = required_object_capacity(); + const auto required_contexts = required_context_capacity(); + const auto required_names = required_name_bytes(); + if (required_objects == std::numeric_limits::max() || + required_contexts == std::numeric_limits::max() || + required_names == std::numeric_limits::max() || + required_objects > object_storage_.size() || + required_objects > MmsStaticObjectTable::maximum_objects || + required_contexts > context_storage_.size() || + required_names > name_storage_.size()) { + return false; + } + + std::copy(base_objects_.begin(), base_objects_.end(), object_storage_.begin()); + std::size_t object_offset = base_objects_.size(); + std::size_t context_offset = 0U; + std::size_t name_offset = 0U; + + for (std::size_t urcb_index = 0U; urcb_index < runtime_->size(); ++urcb_index) { + const auto* definition = runtime_->definition(urcb_index); + if (definition == nullptr) { + return false; + } + for (std::size_t attribute_index = 0U; + attribute_index < attributes_per_control_block; + ++attribute_index) { + const auto suffix = kSuffixes[attribute_index]; + const auto name_size = definition->item.size() + 1U + suffix.size(); + auto* name = name_storage_.data() + name_offset; + std::copy(definition->item.begin(), definition->item.end(), name); + name[definition->item.size()] = '$'; + std::copy( + suffix.begin(), + suffix.end(), + name + definition->item.size() + 1U); + + auto& context = context_storage_[context_offset]; + context = MmsStaticUrcbObjectContext{ + runtime_, + urcb_index, + kAttributes[attribute_index], + now_ms_, + now_context_}; + + const auto attribute = kAttributes[attribute_index]; + object_storage_[object_offset] = MmsStaticObjectEntry{ + definition->domain, + std::string_view{name, name_size}, + type_for(attribute), + read_urcb_attribute, + &context, + false, + writable_attribute(attribute) ? write_urcb_attribute : nullptr, + writable_attribute(attribute) ? &context : nullptr}; + + name_offset += name_size; + ++context_offset; + ++object_offset; + } + } + + table_ = MmsStaticObjectTable{ + std::span{object_storage_.data(), object_offset}}; + if (!table_.valid()) { + table_ = MmsStaticObjectTable{std::span{}}; + return false; + } + + object_count_ = object_offset; + name_bytes_used_ = name_offset; + initialized_ = true; + return true; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_urcb_runtime.cpp b/src/mms/static_urcb_runtime.cpp new file mode 100644 index 000000000..968cd8a2c --- /dev/null +++ b/src/mms/static_urcb_runtime.cpp @@ -0,0 +1,612 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_urcb_runtime.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +constexpr std::uint8_t kAllowedOptionalFirst = 0x7CU; +constexpr std::uint8_t kAllowedOptionalSecond = 0x80U; +constexpr std::uint8_t kAllowedTriggerOptions = 0x7CU; +constexpr std::uint8_t kTriggerIntegrity = 0x08U; + +[[nodiscard]] bool visible_ascii( + const std::string_view text, + const std::size_t maximum_bytes) noexcept { + if (text.empty() || text.size() > maximum_bytes) { + return false; + } + for (const auto character : text) { + const auto byte = static_cast(character); + if (byte == 0U || byte > 0x7FU) { + return false; + } + } + return true; +} + +[[nodiscard]] bool valid_report_id(const std::string_view report_id) noexcept { + return visible_ascii( + report_id, + MmsStaticUrcbState::maximum_report_id_bytes); +} + +[[nodiscard]] bool valid_reference( + const std::string_view domain, + const std::string_view item) noexcept { + return visible_ascii( + domain, + MmsStaticUrcbState::maximum_reference_component_bytes) && + visible_ascii( + item, + MmsStaticUrcbState::maximum_reference_component_bytes) && + domain.size() + 1U + item.size() <= + MmsInformationReportSpanCodec::maximum_reference_bytes; +} + +[[nodiscard]] bool valid_optional_fields( + const std::span optional_fields) noexcept { + return optional_fields.size() == + MmsInformationReportSpanCodec::optional_field_bytes && + (optional_fields[0] & static_cast(~kAllowedOptionalFirst)) == 0U && + (optional_fields[1] & static_cast(~kAllowedOptionalSecond)) == 0U; +} + +[[nodiscard]] bool valid_trigger_options(const std::uint8_t options) noexcept { + return (options & static_cast(~kAllowedTriggerOptions)) == 0U; +} + +template +[[nodiscard]] bool copy_text( + const std::string_view source, + std::array& destination, + std::size_t& length) noexcept { + if (source.empty() || source.size() > destination.size()) { + length = 0U; + return false; + } + std::fill(destination.begin(), destination.end(), '\0'); + std::copy(source.begin(), source.end(), destination.begin()); + length = source.size(); + return true; +} + +[[nodiscard]] std::span as_bytes( + const std::string_view text) noexcept { + return { + reinterpret_cast(text.data()), + text.size()}; +} + +[[nodiscard]] MmsObjectNameView object_name( + const std::string_view domain, + const std::string_view item) noexcept { + return { + MmsObjectNameViewKind::domain_specific, + as_bytes(domain), + as_bytes(item)}; +} + +[[nodiscard]] bool same_name( + const MmsStaticUrcbDefinition& left, + const MmsStaticUrcbDefinition& right) noexcept { + return left.domain == right.domain && left.item == right.item; +} + +void bump_revision(MmsStaticUrcbState& state) noexcept { + state.revision = state.revision == std::numeric_limits::max() + ? 1U + : state.revision + 1U; +} + +[[nodiscard]] std::uint64_t saturating_add( + const std::uint64_t base, + const std::uint64_t delta) noexcept { + return delta > std::numeric_limits::max() - base + ? std::numeric_limits::max() + : base + delta; +} + +[[nodiscard]] std::uint32_t effective_integrity_period( + const MmsStaticUrcbState& state) noexcept { + if (state.integrity_period_ms == 0U) { + return 0U; + } + return state.integrity_period_ms < MmsStaticUrcbRuntime::minimum_integrity_period_ms + ? MmsStaticUrcbRuntime::minimum_integrity_period_ms + : state.integrity_period_ms; +} + +void refresh_integrity_arm( + MmsStaticUrcbState& state, + const std::uint64_t now_ms) noexcept { + const auto period = effective_integrity_period(state); + const bool selected = (state.trigger_options & kTriggerIntegrity) != 0U; + state.integrity_armed = state.enabled && selected && period != 0U; + state.next_integrity_due_ms = state.integrity_armed + ? saturating_add(now_ms, period) + : 0U; +} + +[[nodiscard]] std::uint8_t next_sequence_number( + const std::uint8_t current) noexcept { + return static_cast( + (static_cast(current) + 1U) & 0xFFU); +} + +[[nodiscard]] bool plan_matches_state( + const MmsStaticUrcbEmissionPlan& plan, + const MmsStaticUrcbState& state) noexcept { + if (!state.enabled || plan.revision != state.revision || + plan.reason == MmsStaticUrcbReportReason::none || + plan.sequence_number != next_sequence_number(state.sequence_number)) { + return false; + } + if (plan.reason == MmsStaticUrcbReportReason::general_interrogation) { + return state.general_interrogation_pending; + } + return plan.reason == MmsStaticUrcbReportReason::integrity && state.integrity_armed; +} + +[[nodiscard]] MmsStaticUrcbEncodeResult map_report_result( + const MmsStaticInformationReportEncodeResult& result) noexcept { + MmsStaticUrcbStatus status = MmsStaticUrcbStatus::report_encode_failed; + switch (result.status) { + case MmsStaticInformationReportStatus::response_ready: + status = MmsStaticUrcbStatus::ok; + break; + case MmsStaticInformationReportStatus::data_set_not_found: + status = MmsStaticUrcbStatus::data_set_not_found; + break; + case MmsStaticInformationReportStatus::workspace_too_small: + status = MmsStaticUrcbStatus::workspace_too_small; + break; + case MmsStaticInformationReportStatus::backend_failure: + case MmsStaticInformationReportStatus::object_not_found: + status = MmsStaticUrcbStatus::backend_failure; + break; + case MmsStaticInformationReportStatus::response_buffer_too_small: + status = MmsStaticUrcbStatus::response_buffer_too_small; + break; + case MmsStaticInformationReportStatus::invalid_object_table: + case MmsStaticInformationReportStatus::invalid_data_set_table: + case MmsStaticInformationReportStatus::invalid_report: + status = MmsStaticUrcbStatus::report_encode_failed; + break; + } + return {status, result.bytes_written, result.required_bytes, result.member_count}; +} + +} // namespace + +bool MmsStaticUrcbRuntime::initialize() noexcept { + initialized_ = false; + if (objects_ == nullptr || data_sets_ == nullptr || + !objects_->valid() || !data_sets_->valid() || + !data_sets_->valid_against(*objects_) || + definitions_.size() > maximum_control_blocks || + states_.size() < definitions_.size()) { + return false; + } + + for (std::size_t index = 0U; index < definitions_.size(); ++index) { + const auto& definition = definitions_[index]; + if (!valid_reference(definition.domain, definition.item) || + !valid_report_id(definition.report_id) || + !valid_reference(definition.data_set_domain, definition.data_set_item) || + !valid_optional_fields(definition.optional_fields) || + !valid_trigger_options(definition.trigger_options) || + data_sets_->find(object_name( + definition.data_set_domain, + definition.data_set_item)) == nullptr) { + return false; + } + for (std::size_t earlier = 0U; earlier < index; ++earlier) { + if (same_name(definitions_[earlier], definition)) { + return false; + } + } + } + + for (std::size_t index = 0U; index < definitions_.size(); ++index) { + const auto& definition = definitions_[index]; + auto& state_ref = states_[index]; + state_ref = {}; + if (!copy_text( + definition.report_id, + state_ref.report_id_storage, + state_ref.report_id_size) || + !copy_text( + definition.data_set_domain, + state_ref.data_set_domain_storage, + state_ref.data_set_domain_size) || + !copy_text( + definition.data_set_item, + state_ref.data_set_item_storage, + state_ref.data_set_item_size)) { + return false; + } + state_ref.optional_fields = definition.optional_fields; + state_ref.conf_revision = definition.conf_revision; + state_ref.buffer_time_ms = definition.buffer_time_ms; + state_ref.trigger_options = definition.trigger_options; + state_ref.integrity_period_ms = definition.integrity_period_ms; + state_ref.revision = 1U; + } + + initialized_ = true; + return true; +} + +const MmsStaticUrcbDefinition* MmsStaticUrcbRuntime::definition( + const std::size_t index) const noexcept { + return initialized_ && index < definitions_.size() ? &definitions_[index] : nullptr; +} + +const MmsStaticUrcbState* MmsStaticUrcbRuntime::state( + const std::size_t index) const noexcept { + return initialized_ && index < definitions_.size() ? &states_[index] : nullptr; +} + +MmsStaticUrcbState* MmsStaticUrcbRuntime::state( + const std::size_t index) noexcept { + return initialized_ && index < definitions_.size() ? &states_[index] : nullptr; +} + +bool MmsStaticUrcbRuntime::find_index( + const MmsObjectNameView& name, + std::size_t& index) const noexcept { + index = 0U; + if (!initialized_ || name.kind != MmsObjectNameViewKind::domain_specific) { + return false; + } + for (std::size_t current = 0U; current < definitions_.size(); ++current) { + const auto& definition_ref = definitions_[current]; + if (name.domain.size() != definition_ref.domain.size() || + name.item.size() != definition_ref.item.size()) { + continue; + } + if (std::equal( + name.domain.begin(), + name.domain.end(), + reinterpret_cast(definition_ref.domain.data())) && + std::equal( + name.item.begin(), + name.item.end(), + reinterpret_cast(definition_ref.item.data()))) { + index = current; + return true; + } + } + return false; +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::set_enabled( + const std::size_t index, + const bool enabled, + const std::uint64_t now_ms) noexcept { + auto* state_ref = state(index); + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (state_ref == nullptr) { + return MmsStaticUrcbStatus::index_out_of_range; + } + if (state_ref->enabled == enabled) { + return MmsStaticUrcbStatus::ok; + } + + state_ref->enabled = enabled; + state_ref->general_interrogation_pending = false; + if (enabled) { + state_ref->sequence_number = 0U; + refresh_integrity_arm(*state_ref, now_ms); + } else { + state_ref->integrity_armed = false; + state_ref->next_integrity_due_ms = 0U; + } + bump_revision(*state_ref); + return MmsStaticUrcbStatus::ok; +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::set_reserved( + const std::size_t index, + const bool reserved) noexcept { + auto* state_ref = state(index); + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (state_ref == nullptr) { + return MmsStaticUrcbStatus::index_out_of_range; + } + if (state_ref->reserved != reserved) { + state_ref->reserved = reserved; + bump_revision(*state_ref); + } + return MmsStaticUrcbStatus::ok; +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::set_report_id( + const std::size_t index, + const std::string_view report_id) noexcept { + auto* state_ref = state(index); + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (state_ref == nullptr) { + return MmsStaticUrcbStatus::index_out_of_range; + } + if (state_ref->enabled) { + return MmsStaticUrcbStatus::object_access_denied; + } + if (!valid_report_id(report_id)) { + return MmsStaticUrcbStatus::invalid_value; + } + if (state_ref->report_id() == report_id) { + return MmsStaticUrcbStatus::ok; + } + if (!copy_text( + report_id, + state_ref->report_id_storage, + state_ref->report_id_size)) { + return MmsStaticUrcbStatus::invalid_value; + } + bump_revision(*state_ref); + return MmsStaticUrcbStatus::ok; +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::set_data_set( + const std::size_t index, + const std::string_view domain, + const std::string_view item) noexcept { + auto* state_ref = state(index); + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (state_ref == nullptr) { + return MmsStaticUrcbStatus::index_out_of_range; + } + if (state_ref->enabled) { + return MmsStaticUrcbStatus::object_access_denied; + } + if (!valid_reference(domain, item)) { + return MmsStaticUrcbStatus::invalid_value; + } + if (data_sets_->find(object_name(domain, item)) == nullptr) { + return MmsStaticUrcbStatus::data_set_not_found; + } + if (state_ref->data_set_domain() == domain && state_ref->data_set_item() == item) { + return MmsStaticUrcbStatus::ok; + } + if (!copy_text( + domain, + state_ref->data_set_domain_storage, + state_ref->data_set_domain_size) || + !copy_text( + item, + state_ref->data_set_item_storage, + state_ref->data_set_item_size)) { + return MmsStaticUrcbStatus::invalid_value; + } + bump_revision(*state_ref); + return MmsStaticUrcbStatus::ok; +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::set_optional_fields( + const std::size_t index, + const std::span optional_fields) noexcept { + auto* state_ref = state(index); + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (state_ref == nullptr) { + return MmsStaticUrcbStatus::index_out_of_range; + } + if (state_ref->enabled) { + return MmsStaticUrcbStatus::object_access_denied; + } + if (!valid_optional_fields(optional_fields)) { + return MmsStaticUrcbStatus::invalid_value; + } + const std::array + next{optional_fields[0], optional_fields[1]}; + if (state_ref->optional_fields != next) { + state_ref->optional_fields = next; + bump_revision(*state_ref); + } + return MmsStaticUrcbStatus::ok; +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::set_buffer_time_ms( + const std::size_t index, + const std::uint32_t buffer_time_ms) noexcept { + auto* state_ref = state(index); + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (state_ref == nullptr) { + return MmsStaticUrcbStatus::index_out_of_range; + } + if (state_ref->enabled) { + return MmsStaticUrcbStatus::object_access_denied; + } + if (state_ref->buffer_time_ms != buffer_time_ms) { + state_ref->buffer_time_ms = buffer_time_ms; + bump_revision(*state_ref); + } + return MmsStaticUrcbStatus::ok; +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::set_trigger_options( + const std::size_t index, + const std::uint8_t trigger_options) noexcept { + auto* state_ref = state(index); + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (state_ref == nullptr) { + return MmsStaticUrcbStatus::index_out_of_range; + } + if (state_ref->enabled) { + return MmsStaticUrcbStatus::object_access_denied; + } + if (!valid_trigger_options(trigger_options)) { + return MmsStaticUrcbStatus::invalid_value; + } + if (state_ref->trigger_options != trigger_options) { + state_ref->trigger_options = trigger_options; + bump_revision(*state_ref); + } + return MmsStaticUrcbStatus::ok; +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::set_integrity_period_ms( + const std::size_t index, + const std::uint32_t integrity_period_ms) noexcept { + auto* state_ref = state(index); + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (state_ref == nullptr) { + return MmsStaticUrcbStatus::index_out_of_range; + } + if (state_ref->enabled) { + return MmsStaticUrcbStatus::object_access_denied; + } + if (state_ref->integrity_period_ms != integrity_period_ms) { + state_ref->integrity_period_ms = integrity_period_ms; + bump_revision(*state_ref); + } + return MmsStaticUrcbStatus::ok; +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::request_general_interrogation( + const std::size_t index) noexcept { + auto* state_ref = state(index); + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (state_ref == nullptr) { + return MmsStaticUrcbStatus::index_out_of_range; + } + if (!state_ref->enabled) { + return MmsStaticUrcbStatus::temporarily_unavailable; + } + if (!state_ref->general_interrogation_pending) { + state_ref->general_interrogation_pending = true; + bump_revision(*state_ref); + } + return MmsStaticUrcbStatus::ok; +} + +bool MmsStaticUrcbRuntime::next_due( + const std::uint64_t now_ms, + MmsStaticUrcbEmissionPlan& plan) const noexcept { + plan = {}; + if (!initialized_) { + return false; + } + + for (std::size_t index = 0U; index < definitions_.size(); ++index) { + const auto& state_ref = states_[index]; + if (state_ref.enabled && state_ref.general_interrogation_pending) { + plan.index = index; + plan.revision = state_ref.revision; + plan.sequence_number = next_sequence_number(state_ref.sequence_number); + plan.reason = MmsStaticUrcbReportReason::general_interrogation; + return true; + } + } + for (std::size_t index = 0U; index < definitions_.size(); ++index) { + const auto& state_ref = states_[index]; + if (state_ref.enabled && state_ref.integrity_armed && + now_ms >= state_ref.next_integrity_due_ms) { + plan.index = index; + plan.revision = state_ref.revision; + plan.sequence_number = next_sequence_number(state_ref.sequence_number); + plan.reason = MmsStaticUrcbReportReason::integrity; + return true; + } + } + return false; +} + +MmsStaticUrcbEncodeResult MmsStaticUrcbRuntime::encode( + const MmsStaticUrcbEmissionPlan& plan, + const std::span report_time, + const std::span destination, + const std::span workspace) const noexcept { + if (!initialized_ || objects_ == nullptr || data_sets_ == nullptr) { + return {MmsStaticUrcbStatus::invalid_runtime, 0U, 0U, 0U}; + } + if (plan.index >= definitions_.size()) { + return {MmsStaticUrcbStatus::index_out_of_range, 0U, 0U, 0U}; + } + const auto& state_ref = states_[plan.index]; + if (!plan_matches_state(plan, state_ref)) { + return {MmsStaticUrcbStatus::stale_plan, 0U, 0U, 0U}; + } + + const auto data_set_name = object_name( + state_ref.data_set_domain(), + state_ref.data_set_item()); + MmsStaticInformationReportInput report; + report.report_id = state_ref.report_id(); + report.optional_fields = state_ref.optional_fields; + report.sequence_number = plan.sequence_number; + report.report_time = report_time; + report.buffered = false; + report.buffer_overflow = false; + report.entry_id = {}; + report.conf_revision = state_ref.conf_revision; + report.reason_for_inclusion = static_cast(plan.reason); + + return map_report_result( + MmsStaticInformationReportEncoder::encode_data_set_snapshot_into( + *objects_, + *data_sets_, + data_set_name, + report, + destination, + workspace)); +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::commit( + const MmsStaticUrcbEmissionPlan& plan, + const std::uint64_t now_ms) noexcept { + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (plan.index >= definitions_.size()) { + return MmsStaticUrcbStatus::index_out_of_range; + } + auto& state_ref = states_[plan.index]; + if (!plan_matches_state(plan, state_ref)) { + return MmsStaticUrcbStatus::stale_plan; + } + + state_ref.sequence_number = plan.sequence_number; + if (plan.reason == MmsStaticUrcbReportReason::general_interrogation) { + state_ref.general_interrogation_pending = false; + } else if (plan.reason == MmsStaticUrcbReportReason::integrity) { + const auto period = effective_integrity_period(state_ref); + state_ref.next_integrity_due_ms = period == 0U + ? 0U + : saturating_add(now_ms, period); + state_ref.integrity_armed = period != 0U && + (state_ref.trigger_options & kTriggerIntegrity) != 0U && state_ref.enabled; + } else { + return MmsStaticUrcbStatus::stale_plan; + } + bump_revision(state_ref); + return MmsStaticUrcbStatus::ok; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_write.cpp b/src/mms/static_write.cpp new file mode 100644 index 000000000..5ffa21614 --- /dev/null +++ b/src/mms/static_write.cpp @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_object_table.hpp" + +#include +#include + +namespace ar::iec61850::mms { + +bool MmsStaticObjectTable::try_resolve_write_request( + const MmsWriteRequestView& request, + const std::span resolved, + std::size_t& resolved_count) const noexcept { + resolved_count = 0U; + if (request.variable_count == 0U || + request.variable_count != request.data_count || + request.variable_count > resolved.size() || + request.variable_count > MmsServiceSpanCodec::maximum_variables) { + return false; + } + for (std::size_t index = 0U; index < request.variable_count; ++index) { + MmsObjectNameView name; + if (!request.try_variable(index, name)) { + resolved_count = 0U; + return false; + } + resolved[index] = find(name); + ++resolved_count; + } + return true; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/utc_time.cpp b/src/mms/utc_time.cpp index 0abb34c5a..67439e4f7 100644 --- a/src/mms/utc_time.cpp +++ b/src/mms/utc_time.cpp @@ -7,13 +7,18 @@ #include #include #include +#if !defined(ARIEC61850_NO_EXCEPTIONS) #include +#endif namespace ar::iec61850::mms { -Iec61850UtcTime Iec61850UtcTime::from_bytes(const std::span bytes) { +bool Iec61850UtcTime::try_from_bytes( + const std::span bytes, + Iec61850UtcTime& result) noexcept { if (bytes.size() != 8U) { - throw std::invalid_argument("IEC 61850 UTC time requires exactly 8 bytes."); + result = {}; + return false; } const auto seconds = @@ -30,8 +35,19 @@ Iec61850UtcTime Iec61850UtcTime::from_bytes(const std::span const auto timestamp = std::chrono::system_clock::time_point{std::chrono::seconds{seconds}} + std::chrono::duration_cast( fraction_duration{fraction}); - return {timestamp, bytes[7]}; + result = {timestamp, bytes[7]}; + return true; +} + +#if !defined(ARIEC61850_NO_EXCEPTIONS) +Iec61850UtcTime Iec61850UtcTime::from_bytes(const std::span bytes) { + Iec61850UtcTime result{}; + if (!try_from_bytes(bytes, result)) { + throw std::invalid_argument("IEC 61850 UTC time requires exactly 8 bytes."); + } + return result; } +#endif bool Iec61850UtcTime::try_write_bytes( const std::span destination) const noexcept { @@ -64,6 +80,7 @@ bool Iec61850UtcTime::try_write_bytes( return true; } +#if !defined(ARIEC61850_NO_EXCEPTIONS) std::vector Iec61850UtcTime::to_bytes() const { std::array bytes{}; if (!try_write_bytes(bytes)) { @@ -71,5 +88,6 @@ std::vector Iec61850UtcTime::to_bytes() const { } return {bytes.begin(), bytes.end()}; } +#endif } // namespace ar::iec61850::mms diff --git a/src/mms/write_span.cpp b/src/mms/write_span.cpp new file mode 100644 index 000000000..1871f0852 --- /dev/null +++ b/src/mms/write_span.cpp @@ -0,0 +1,458 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/services_span.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/asn1/ber_span_writer.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] bool read_u32( + const asn1::BerTlvView& tlv, + std::uint32_t& value) noexcept { + const auto decoded = asn1::BerSpanReader::read_uint32(tlv); + if (!decoded) { + value = 0U; + return false; + } + value = *decoded; + return true; +} + +[[nodiscard]] bool valid_mms_data_tlv( + const asn1::BerTlvView& tlv) noexcept { + if (tlv.tag_class != asn1::BerClass::context_specific) { + return false; + } + if (tlv.tag_number == 1 || tlv.tag_number == 2) { + return tlv.constructed; + } + return tlv.tag_number >= 3 && tlv.tag_number <= 17 && !tlv.constructed; +} + +[[nodiscard]] std::size_t minimal_unsigned_size(std::uint32_t value) noexcept { + std::size_t bytes = 1U; + while (value > 0xFFU) { + ++bytes; + value >>= 8U; + } + return bytes; +} + +[[nodiscard]] bool needs_positive_prefix( + const std::uint32_t value, + const std::size_t minimal_bytes) noexcept { + const auto shift = static_cast((minimal_bytes - 1U) * 8U); + return ((value >> shift) & 0x80U) != 0U; +} + +[[nodiscard]] std::size_t positive_integer_size(const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + return minimal + (needs_positive_prefix(value, minimal) ? 1U : 0U); +} + +[[nodiscard]] bool write_positive_integer( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + if (needs_positive_prefix(value, minimal) && !writer.write_byte(0x00U)) { + return false; + } + for (std::size_t index = minimal; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte(static_cast( + (value >> shift) & 0xFFU))) { + return false; + } + } + return true; +} + +[[nodiscard]] std::optional add_size( + const std::optional left, + const std::optional right) noexcept { + if (!left || !right || *right > std::numeric_limits::max() - *left) { + return std::nullopt; + } + return *left + *right; +} + +struct ConfirmedResponseSizing final { + std::size_t invoke_value{}; + std::size_t outer_content{}; + std::size_t required{}; +}; + +[[nodiscard]] std::optional confirmed_response_sizing( + const std::uint32_t invoke_id, + const std::size_t service_content) noexcept { + if (invoke_id > MmsPduSpanCodec::maximum_invoke_id) { + return std::nullopt; + } + const auto invoke_value = positive_integer_size(invoke_id); + const auto invoke_tlv = asn1::BerSpanWriter::tlv_size(2, invoke_value); + const auto service_tlv = asn1::BerSpanWriter::tlv_size(5, service_content); + const auto outer_content = add_size(invoke_tlv, service_tlv); + if (!outer_content) { + return std::nullopt; + } + const auto required = asn1::BerSpanWriter::tlv_size(1, *outer_content); + if (!required || *required > MmsPduSpanCodec::maximum_pdu_bytes) { + return std::nullopt; + } + return ConfirmedResponseSizing{invoke_value, *outer_content, *required}; +} + +[[nodiscard]] bool write_confirmed_response_prefix( + asn1::BerSpanWriter& writer, + const ConfirmedResponseSizing& sizing, + const std::uint32_t invoke_id, + const std::size_t service_content) noexcept { + return writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, sizing.outer_content) && + writer.write_tlv_header( + asn1::BerClass::universal, false, 2, sizing.invoke_value) && + write_positive_integer(writer, invoke_id) && + writer.write_tlv_header( + asn1::BerClass::context_specific, true, 5, service_content); +} + +[[nodiscard]] bool try_read_variable_definition( + const std::span variable_list, + const std::size_t wanted, + MmsObjectNameView& name) noexcept { + name = {}; + std::size_t offset = 0U; + std::size_t current = 0U; + while (offset < variable_list.size()) { + asn1::BerTlvView definition; + if (!asn1::BerSpanReader::try_read_tlv(variable_list, offset, definition) || + definition.tag_class != asn1::BerClass::universal || + definition.tag_number != 16 || !definition.constructed) { + return false; + } + asn1::BerTlvView wrapper; + if (!asn1::BerSpanReader::try_read_exact(definition.value, wrapper) || + wrapper.tag_class != asn1::BerClass::context_specific || + wrapper.tag_number != 0 || !wrapper.constructed) { + return false; + } + MmsObjectNameView decoded; + if (!MmsServiceSpanCodec::try_decode_object_name_view(wrapper.value, decoded)) { + return false; + } + if (current == wanted) { + name = decoded; + return true; + } + ++current; + } + return false; +} + +[[nodiscard]] bool try_read_data_value( + const std::span data_list, + const std::size_t wanted, + std::span& encoded_data) noexcept { + encoded_data = {}; + std::size_t offset = 0U; + std::size_t current = 0U; + while (offset < data_list.size()) { + const auto start = offset; + asn1::BerTlvView value; + if (!asn1::BerSpanReader::try_read_tlv(data_list, offset, value) || + !valid_mms_data_tlv(value)) { + return false; + } + if (current == wanted) { + encoded_data = data_list.subspan(start, offset - start); + return true; + } + ++current; + } + return false; +} + +[[nodiscard]] bool try_read_write_result( + const std::span result_list, + const std::size_t wanted, + MmsWriteAccessResultView& result) noexcept { + result = {}; + std::size_t offset = 0U; + std::size_t current = 0U; + while (offset < result_list.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv(result_list, offset, field) || + field.tag_class != asn1::BerClass::context_specific || field.constructed) { + return false; + } + MmsWriteAccessResultView decoded; + if (field.tag_number == 1) { + if (!field.value.empty()) { + return false; + } + decoded.success = true; + } else if (field.tag_number == 0) { + if (!read_u32(field, decoded.failure_code)) { + return false; + } + decoded.success = false; + } else { + return false; + } + if (current == wanted) { + result = decoded; + return true; + } + ++current; + } + return false; +} + +} // namespace + +bool MmsWriteRequestView::try_variable( + const std::size_t index, + MmsObjectNameView& name) const noexcept { + if (index >= variable_count) { + name = {}; + return false; + } + return try_read_variable_definition(variable_list, index, name); +} + +bool MmsWriteRequestView::try_value( + const std::size_t index, + std::span& encoded_data) const noexcept { + if (index >= data_count) { + encoded_data = {}; + return false; + } + return try_read_data_value(data_list, index, encoded_data); +} + +bool MmsWriteResponseView::try_result( + const std::size_t index, + MmsWriteAccessResultView& result) const noexcept { + if (implicit_single_success) { + if (index != 0U) { + result = {}; + return false; + } + result = MmsWriteAccessResultView{true, 0U}; + return true; + } + if (index >= result_count) { + result = {}; + return false; + } + return try_read_write_result(result_list, index, result); +} + +bool MmsServiceSpanCodec::try_decode_write_request( + const MmsConfirmedPduView& confirmed, + MmsWriteRequestView& request) noexcept { + request = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_request || + confirmed.service_tag != 5 || !confirmed.service_constructed) { + return false; + } + + std::size_t offset = 0U; + asn1::BerTlvView variables; + asn1::BerTlvView data; + if (!asn1::BerSpanReader::try_read_tlv(confirmed.service_value, offset, variables) || + !asn1::BerSpanReader::try_read_tlv(confirmed.service_value, offset, data) || + offset != confirmed.service_value.size() || + variables.tag_class != asn1::BerClass::context_specific || + variables.tag_number != 0 || !variables.constructed || + data.tag_class != asn1::BerClass::context_specific || + data.tag_number != 0 || !data.constructed) { + return false; + } + + std::size_t variable_offset = 0U; + std::size_t variable_count = 0U; + while (variable_offset < variables.value.size()) { + if (variable_count >= maximum_variables) { + return false; + } + const auto before = variable_offset; + asn1::BerTlvView definition; + if (!asn1::BerSpanReader::try_read_tlv( + variables.value, variable_offset, definition)) { + return false; + } + MmsObjectNameView ignored; + if (!try_read_variable_definition( + variables.value.subspan(before, variable_offset - before), 0U, ignored)) { + return false; + } + ++variable_count; + } + + std::size_t data_offset = 0U; + std::size_t data_count = 0U; + while (data_offset < data.value.size()) { + if (data_count >= maximum_variables) { + return false; + } + asn1::BerTlvView value; + if (!asn1::BerSpanReader::try_read_tlv(data.value, data_offset, value) || + !valid_mms_data_tlv(value)) { + return false; + } + ++data_count; + } + + if (variable_count == 0U || variable_count != data_count) { + return false; + } + request.invoke_id = confirmed.invoke_id; + request.variable_list = variables.value; + request.variable_count = variable_count; + request.data_list = data.value; + request.data_count = data_count; + return true; +} + +bool MmsServiceSpanCodec::try_decode_write_request( + const std::span mms_pdu, + MmsWriteRequestView& request) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_request_view(mms_pdu, confirmed) && + try_decode_write_request(confirmed, request); +} + +bool MmsServiceSpanCodec::try_decode_write_response( + const MmsConfirmedPduView& confirmed, + MmsWriteResponseView& response) noexcept { + response = {}; + if (confirmed.kind != MmsWirePduKind::confirmed_response || + confirmed.service_tag != 5) { + return false; + } + response.invoke_id = confirmed.invoke_id; + if (!confirmed.service_constructed) { + if (!confirmed.service_value.empty()) { + response = {}; + return false; + } + response.implicit_single_success = true; + response.result_count = 1U; + return true; + } + + std::size_t offset = 0U; + std::size_t count = 0U; + while (offset < confirmed.service_value.size()) { + if (count >= maximum_variables) { + response = {}; + return false; + } + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv( + confirmed.service_value, offset, field) || + field.tag_class != asn1::BerClass::context_specific || field.constructed) { + response = {}; + return false; + } + if (field.tag_number == 1) { + if (!field.value.empty()) { + response = {}; + return false; + } + } else if (field.tag_number == 0) { + std::uint32_t ignored{}; + if (!read_u32(field, ignored)) { + response = {}; + return false; + } + } else { + response = {}; + return false; + } + ++count; + } + if (count == 0U) { + response = {}; + return false; + } + response.result_list = confirmed.service_value; + response.result_count = count; + return true; +} + +bool MmsServiceSpanCodec::try_decode_write_response( + const std::span mms_pdu, + MmsWriteResponseView& response) noexcept { + MmsConfirmedPduView confirmed; + return MmsPduSpanCodec::try_decode_confirmed_response_view(mms_pdu, confirmed) && + try_decode_write_response(confirmed, response); +} + +wire::EncodeResult MmsServiceSpanCodec::encode_write_response_into( + const std::uint32_t invoke_id, + const std::span results, + const std::span destination) noexcept { + if (results.empty() || results.size() > maximum_variables) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + std::size_t service_content = 0U; + for (const auto& result : results) { + std::optional encoded; + if (result.success) { + encoded = asn1::BerSpanWriter::tlv_size(1, 0U); + } else { + encoded = asn1::BerSpanWriter::tlv_size( + 0, positive_integer_size(result.failure_code)); + } + if (!encoded || *encoded > std::numeric_limits::max() - service_content) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + service_content += *encoded; + } + + const auto sizing = confirmed_response_sizing(invoke_id, service_content); + if (!sizing) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < sizing->required) { + return {wire::EncodeStatus::buffer_too_small, 0U, sizing->required}; + } + + asn1::BerSpanWriter writer{destination.first(sizing->required)}; + if (!write_confirmed_response_prefix( + writer, *sizing, invoke_id, service_content)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + for (const auto& result : results) { + if (result.success) { + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 1, 0U)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + } else { + const auto failure_value = positive_integer_size(result.failure_code); + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, failure_value) || + !write_positive_integer(writer, result.failure_code)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + } + } + if (writer.size() != sizing->required) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizing->required}; + } + return {wire::EncodeStatus::ok, sizing->required, sizing->required}; +} + +} // namespace ar::iec61850::mms diff --git a/src/osi/cotp_span.cpp b/src/osi/cotp_span.cpp new file mode 100644 index 000000000..54a4cdb65 --- /dev/null +++ b/src/osi/cotp_span.cpp @@ -0,0 +1,432 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/osi/cotp_span.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::osi { +namespace { + +[[nodiscard]] std::uint16_t read_be_u16( + const std::span bytes, + const std::size_t offset) noexcept { + return static_cast( + (static_cast(bytes[offset]) << 8U) | + static_cast(bytes[offset + 1U])); +} + +void write_be_u16( + const std::span bytes, + const std::size_t offset, + const std::uint16_t value) noexcept { + bytes[offset] = static_cast((value >> 8U) & 0xFFU); + bytes[offset + 1U] = static_cast(value & 0xFFU); +} + +[[nodiscard]] bool encoded_parameters_size( + const std::span parameters, + std::size_t& total) noexcept { + total = 0U; + for (const auto& parameter : parameters) { + if (parameter.value.size() > 248U) { + total = 0U; + return false; + } + const auto encoded = 2U + parameter.value.size(); + if (encoded > 248U - total) { + total = 0U; + return false; + } + total += encoded; + } + return true; +} + +[[nodiscard]] bool write_parameter( + const std::span destination, + std::size_t& offset, + const std::uint8_t code, + const std::span value) noexcept { + if (value.size() > 248U || destination.size() - offset < value.size() + 2U) { + return false; + } + destination[offset++] = code; + destination[offset++] = static_cast(value.size()); + std::copy( + value.begin(), + value.end(), + destination.begin() + static_cast(offset)); + offset += value.size(); + return true; +} + +[[nodiscard]] wire::EncodeResult encode_reference_into( + const std::uint8_t code, + const std::uint16_t destination_reference, + const std::uint16_t source_reference, + const std::uint8_t class_or_reason, + const std::span parameters, + const std::span destination) noexcept { + std::size_t parameter_bytes{}; + if (!encoded_parameters_size(parameters, parameter_bytes)) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const auto length_indicator = 6U + parameter_bytes; + const auto required = length_indicator + 1U; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + + destination[0] = static_cast(length_indicator); + destination[1] = code; + write_be_u16(destination, 2U, destination_reference); + write_be_u16(destination, 4U, source_reference); + destination[6] = class_or_reason; + + std::size_t offset = 7U; + for (const auto& parameter : parameters) { + if (!write_parameter(destination, offset, parameter.code, parameter.value)) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + } + if (offset != required) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] bool validate_parameter_bytes( + const std::span bytes) noexcept { + std::size_t offset = 0U; + while (offset < bytes.size()) { + if (bytes.size() - offset < 2U) { + return false; + } + const auto length = static_cast(bytes[offset + 1U]); + offset += 2U; + if (length > bytes.size() - offset) { + return false; + } + offset += length; + } + return offset == bytes.size(); +} + +[[nodiscard]] bool selected_tpdu_size( + const CotpTpduView& request, + const std::uint8_t maximum_tpdu_size_code, + std::uint8_t& selected) noexcept { + std::size_t ignored{}; + if (!CotpSpanCodec::try_tpdu_size_bytes(maximum_tpdu_size_code, ignored)) { + return false; + } + + std::span offered; + if (!request.try_parameter(CotpSpanCodec::tpdu_size_parameter, offered) || + offered.size() != 1U) { + selected = maximum_tpdu_size_code; + return true; + } + + if (!CotpSpanCodec::try_tpdu_size_bytes(offered[0], ignored)) { + return false; + } + selected = std::min(offered[0], maximum_tpdu_size_code); + return true; +} + +[[nodiscard]] bool mirrored_parameter_size( + const CotpTpduView& request, + std::size_t& total) noexcept { + // The CC always starts with C0 / TPDU size (3 encoded bytes). + total = 3U; + std::size_t offset = 0U; + while (offset < request.parameter_bytes.size()) { + if (request.parameter_bytes.size() - offset < 2U) { + total = 0U; + return false; + } + const auto code = request.parameter_bytes[offset]; + const auto length = static_cast(request.parameter_bytes[offset + 1U]); + offset += 2U; + if (length > request.parameter_bytes.size() - offset) { + total = 0U; + return false; + } + + if ((code == CotpSpanCodec::source_tsap_parameter || + code == CotpSpanCodec::destination_tsap_parameter) && + length != 0U) { + const auto encoded = length + 2U; + if (length > 248U || encoded > 248U - total) { + total = 0U; + return false; + } + total += encoded; + } + offset += length; + } + return true; +} + +} // namespace + +bool CotpTpduView::try_parameter( + const std::uint8_t parameter_code, + std::span& value) const noexcept { + value = {}; + std::size_t offset = 0U; + while (offset < parameter_bytes.size()) { + if (parameter_bytes.size() - offset < 2U) { + return false; + } + const auto code = parameter_bytes[offset]; + const auto length = static_cast(parameter_bytes[offset + 1U]); + offset += 2U; + if (length > parameter_bytes.size() - offset) { + return false; + } + if (code == parameter_code) { + value = parameter_bytes.subspan(offset, length); + return true; + } + offset += length; + } + return false; +} + +CotpWireKind CotpSpanCodec::kind_from_code(const std::uint8_t code) noexcept { + switch (code) { + case connection_request_code: + return CotpWireKind::connection_request; + case connection_confirm_code: + return CotpWireKind::connection_confirm; + case data_code: + return CotpWireKind::data; + case disconnect_request_code: + return CotpWireKind::disconnect_request; + case error_code: + return CotpWireKind::error; + default: + return CotpWireKind::unknown; + } +} + +bool CotpSpanCodec::try_decode_view( + const std::span bytes, + CotpTpduView& tpdu) noexcept { + tpdu = {}; + if (bytes.size() < 2U) { + return false; + } + + const auto length_indicator = static_cast(bytes[0]); + const auto header_end = length_indicator + 1U; + if (length_indicator < 2U || header_end > bytes.size()) { + return false; + } + + const auto kind = kind_from_code(bytes[1]); + if (kind == CotpWireKind::unknown) { + return false; + } + + tpdu.code = bytes[1]; + tpdu.kind = kind; + tpdu.length_indicator = bytes[0]; + + if (kind == CotpWireKind::data) { + if (length_indicator != 2U || header_end != 3U) { + tpdu = {}; + return false; + } + tpdu.end_of_transmission = (bytes[2] & 0x80U) != 0U; + tpdu.tpdu_number = static_cast(bytes[2] & 0x7FU); + tpdu.user_data = bytes.subspan(3U); + return true; + } + + if (length_indicator < 6U || bytes.size() < 7U) { + tpdu = {}; + return false; + } + + tpdu.destination_reference = read_be_u16(bytes, 2U); + tpdu.source_reference = read_be_u16(bytes, 4U); + tpdu.class_or_reason = bytes[6]; + tpdu.end_of_transmission = true; + tpdu.parameter_bytes = bytes.subspan(7U, header_end - 7U); + tpdu.user_data = bytes.subspan(header_end); + + if (!validate_parameter_bytes(tpdu.parameter_bytes) || + ((kind == CotpWireKind::connection_request || + kind == CotpWireKind::connection_confirm) && + !tpdu.user_data.empty())) { + tpdu = {}; + return false; + } + return true; +} + +bool CotpSpanCodec::try_tpdu_size_bytes( + const std::uint8_t tpdu_size_code, + std::size_t& bytes) noexcept { + bytes = 0U; + if (tpdu_size_code < 0x07U || tpdu_size_code > 0x0FU) { + return false; + } + bytes = static_cast(1U) << tpdu_size_code; + return true; +} + +wire::EncodeResult CotpSpanCodec::encode_data_into( + const std::span user_data, + const std::span destination, + const bool end_of_transmission, + const std::uint8_t tpdu_number) noexcept { + if (tpdu_number > 0x7FU || + user_data.size() > std::numeric_limits::max() - 3U) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const auto required = user_data.size() + 3U; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + + destination[0] = 0x02U; + destination[1] = data_code; + destination[2] = static_cast( + (end_of_transmission ? 0x80U : 0x00U) | tpdu_number); + std::copy( + user_data.begin(), + user_data.end(), + destination.begin() + 3); + return {wire::EncodeStatus::ok, required, required}; +} + +wire::EncodeResult CotpSpanCodec::encode_connection_request_into( + const std::uint16_t source_reference, + const std::span parameters, + const std::span destination, + const std::uint16_t destination_reference, + const std::uint8_t transport_class) noexcept { + return encode_reference_into( + connection_request_code, + destination_reference, + source_reference, + transport_class, + parameters, + destination); +} + +wire::EncodeResult CotpSpanCodec::encode_connection_confirm_into( + const std::uint16_t destination_reference, + const std::uint16_t source_reference, + const std::span destination, + const std::uint8_t maximum_tpdu_size_code) noexcept { + std::size_t ignored{}; + if (!try_tpdu_size_bytes(maximum_tpdu_size_code, ignored)) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const std::uint8_t value = maximum_tpdu_size_code; + const CotpParameterView parameter{ + tpdu_size_parameter, + std::span{&value, 1U}}; + return encode_reference_into( + connection_confirm_code, + destination_reference, + source_reference, + 0U, + std::span{¶meter, 1U}, + destination); +} + +wire::EncodeResult CotpSpanCodec::encode_connection_confirm_from_request_into( + const CotpTpduView& connection_request, + const std::uint16_t source_reference, + const std::span destination, + const std::uint8_t maximum_tpdu_size_code) noexcept { + if (connection_request.kind != CotpWireKind::connection_request || + !connection_request.user_data.empty()) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + std::uint8_t selected{}; + std::size_t parameter_bytes{}; + if (!selected_tpdu_size( + connection_request, maximum_tpdu_size_code, selected) || + !mirrored_parameter_size(connection_request, parameter_bytes)) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const auto length_indicator = 6U + parameter_bytes; + const auto required = length_indicator + 1U; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + + destination[0] = static_cast(length_indicator); + destination[1] = connection_confirm_code; + write_be_u16(destination, 2U, connection_request.source_reference); + write_be_u16(destination, 4U, source_reference); + destination[6] = 0U; + + std::size_t output_offset = 7U; + const std::span selected_span{&selected, 1U}; + if (!write_parameter( + destination, + output_offset, + tpdu_size_parameter, + selected_span)) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + + std::size_t input_offset = 0U; + while (input_offset < connection_request.parameter_bytes.size()) { + if (connection_request.parameter_bytes.size() - input_offset < 2U) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + const auto code = connection_request.parameter_bytes[input_offset]; + const auto length = static_cast( + connection_request.parameter_bytes[input_offset + 1U]); + input_offset += 2U; + if (length > connection_request.parameter_bytes.size() - input_offset) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + const auto value = connection_request.parameter_bytes.subspan( + input_offset, length); + + if (code == source_tsap_parameter && !value.empty()) { + if (!write_parameter( + destination, + output_offset, + destination_tsap_parameter, + value)) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + } else if (code == destination_tsap_parameter && !value.empty()) { + if (!write_parameter( + destination, + output_offset, + source_tsap_parameter, + value)) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + } + input_offset += length; + } + + if (output_offset != required) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + return {wire::EncodeStatus::ok, required, required}; +} + +} // namespace ar::iec61850::osi diff --git a/src/osi/presentation_span.cpp b/src/osi/presentation_span.cpp new file mode 100644 index 000000000..b0d535793 --- /dev/null +++ b/src/osi/presentation_span.cpp @@ -0,0 +1,728 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/osi/presentation_span.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/asn1/ber_span_writer.hpp" +#include "ariec61850/osi/session_span.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::osi { +namespace { + +constexpr std::array kAcseAbstractSyntax{ + 0x52U, 0x01U, 0x00U, 0x01U}; +constexpr std::array kMmsAbstractSyntax{ + 0x28U, 0xCAU, 0x22U, 0x02U, 0x01U}; +constexpr std::array kBerTransferSyntax{0x51U, 0x01U}; + +[[nodiscard]] std::size_t unsigned_integer_size(std::uint32_t value) noexcept { + std::size_t size = 1U; + while (value > 0xFFU) { + ++size; + value >>= 8U; + } + return size; +} + +[[nodiscard]] bool write_unsigned_integer( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto size = unsigned_integer_size(value); + for (std::size_t index = size; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte(static_cast( + (value >> shift) & 0xFFU))) { + return false; + } + } + return true; +} + +[[nodiscard]] std::optional add_size( + const std::optional left, + const std::optional right) noexcept { + if (!left || !right || *right > std::numeric_limits::max() - *left) { + return std::nullopt; + } + return *left + *right; +} + +[[nodiscard]] bool valid_oid( + const std::span value) noexcept { + return !value.empty() && value.size() <= PresentationSpanCodec::maximum_oid_bytes; +} + +[[nodiscard]] bool read_positive_integer( + const asn1::BerTlvView& tlv, + std::uint32_t& value) noexcept { + const auto decoded = asn1::BerSpanReader::read_uint32(tlv); + if (!decoded || *decoded == 0U) { + value = 0U; + return false; + } + value = *decoded; + return true; +} + +struct ContextDefinitionView final { + std::uint32_t id{}; + std::span abstract_syntax{}; + std::span first_transfer_syntax{}; + std::size_t transfer_syntax_count{}; +}; + +[[nodiscard]] bool parse_context_definition( + const asn1::BerTlvView& definition, + ContextDefinitionView& context) noexcept { + context = {}; + if (definition.tag_class != asn1::BerClass::universal || + definition.tag_number != 16 || !definition.constructed) { + return false; + } + + bool saw_id = false; + bool saw_abstract = false; + bool saw_transfers = false; + std::size_t offset = 0U; + while (offset < definition.value.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv(definition.value, offset, field)) { + return false; + } + + if (field.tag_class == asn1::BerClass::universal && + field.tag_number == 2 && !field.constructed) { + if (saw_id || !read_positive_integer(field, context.id)) { + return false; + } + saw_id = true; + } else if (field.tag_class == asn1::BerClass::universal && + field.tag_number == 6 && !field.constructed) { + if (saw_abstract || !valid_oid(field.value)) { + return false; + } + context.abstract_syntax = field.value; + saw_abstract = true; + } else if (field.tag_class == asn1::BerClass::universal && + field.tag_number == 16 && field.constructed) { + if (saw_transfers) { + return false; + } + std::size_t transfer_offset = 0U; + while (transfer_offset < field.value.size()) { + if (context.transfer_syntax_count >= + PresentationSpanCodec::maximum_contexts) { + return false; + } + asn1::BerTlvView syntax; + if (!asn1::BerSpanReader::try_read_tlv( + field.value, transfer_offset, syntax) || + syntax.tag_class != asn1::BerClass::universal || + syntax.tag_number != 6 || syntax.constructed || + !valid_oid(syntax.value)) { + return false; + } + if (context.transfer_syntax_count == 0U) { + context.first_transfer_syntax = syntax.value; + } + ++context.transfer_syntax_count; + } + if (context.transfer_syntax_count == 0U) { + return false; + } + saw_transfers = true; + } else { + return false; + } + } + return saw_id && saw_abstract && saw_transfers; +} + +[[nodiscard]] bool validate_context_list( + const std::span list, + std::size_t& count) noexcept { + count = 0U; + std::array ids{}; + std::size_t offset = 0U; + while (offset < list.size()) { + if (count >= PresentationSpanCodec::maximum_contexts) { + count = 0U; + return false; + } + asn1::BerTlvView definition; + ContextDefinitionView context; + if (!asn1::BerSpanReader::try_read_tlv(list, offset, definition) || + !parse_context_definition(definition, context)) { + count = 0U; + return false; + } + for (std::size_t index = 0U; index < count; ++index) { + if (ids[index] == context.id) { + count = 0U; + return false; + } + } + ids[count++] = context.id; + } + return count != 0U; +} + +[[nodiscard]] bool find_context_by_abstract_syntax( + const std::span list, + const std::span oid, + std::uint32_t& id) noexcept { + id = 0U; + std::size_t offset = 0U; + while (offset < list.size()) { + asn1::BerTlvView definition; + ContextDefinitionView context; + if (!asn1::BerSpanReader::try_read_tlv(list, offset, definition) || + !parse_context_definition(definition, context)) { + return false; + } + if (context.abstract_syntax.size() == oid.size() && + std::equal( + context.abstract_syntax.begin(), + context.abstract_syntax.end(), + oid.begin())) { + id = context.id; + return true; + } + } + return false; +} + +[[nodiscard]] bool context_id_exists( + const std::span list, + const std::uint32_t id) noexcept { + std::size_t offset = 0U; + while (offset < list.size()) { + asn1::BerTlvView definition; + ContextDefinitionView context; + if (!asn1::BerSpanReader::try_read_tlv(list, offset, definition) || + !parse_context_definition(definition, context)) { + return false; + } + if (context.id == id) { + return true; + } + } + return false; +} + +struct FullyEncodedSizes final { + std::size_t id_value{}; + std::size_t id_tlv{}; + std::size_t payload_tlv{}; + std::size_t sequence_content{}; + std::size_t sequence_tlv{}; + std::size_t outer_tlv{}; +}; + +[[nodiscard]] std::optional calculate_fully_encoded_sizes( + const std::uint32_t context_id, + const std::size_t payload_bytes) noexcept { + if (context_id == 0U || payload_bytes > PresentationSpanCodec::maximum_ppdu_bytes) { + return std::nullopt; + } + + FullyEncodedSizes sizes; + sizes.id_value = unsigned_integer_size(context_id); + const auto id_tlv = asn1::BerSpanWriter::tlv_size(2, sizes.id_value); + const auto payload_tlv = asn1::BerSpanWriter::tlv_size(0, payload_bytes); + const auto sequence_content = add_size(id_tlv, payload_tlv); + if (!id_tlv || !payload_tlv || !sequence_content) { + return std::nullopt; + } + const auto sequence_tlv = asn1::BerSpanWriter::tlv_size(16, *sequence_content); + if (!sequence_tlv) { + return std::nullopt; + } + const auto outer_tlv = asn1::BerSpanWriter::tlv_size(1, *sequence_tlv); + if (!outer_tlv || *outer_tlv > PresentationSpanCodec::maximum_ppdu_bytes) { + return std::nullopt; + } + + sizes.id_tlv = *id_tlv; + sizes.payload_tlv = *payload_tlv; + sizes.sequence_content = *sequence_content; + sizes.sequence_tlv = *sequence_tlv; + sizes.outer_tlv = *outer_tlv; + return sizes; +} + +[[nodiscard]] bool decode_fully_encoded_outer( + const asn1::BerTlvView& outer, + PresentationPdvView& pdv) noexcept { + pdv = {}; + if (outer.tag_class != asn1::BerClass::application || + outer.tag_number != 1 || !outer.constructed) { + return false; + } + + asn1::BerTlvView sequence; + if (!asn1::BerSpanReader::try_read_exact(outer.value, sequence) || + sequence.tag_class != asn1::BerClass::universal || + sequence.tag_number != 16 || !sequence.constructed) { + return false; + } + + bool saw_context_id = false; + bool saw_payload = false; + std::size_t offset = 0U; + while (offset < sequence.value.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv(sequence.value, offset, field)) { + pdv = {}; + return false; + } + + if (field.tag_class == asn1::BerClass::universal && + field.tag_number == 2 && !field.constructed) { + if (saw_context_id || !read_positive_integer(field, pdv.context_id)) { + pdv = {}; + return false; + } + saw_context_id = true; + } else if (field.tag_class == asn1::BerClass::context_specific && + field.tag_number == 0 && field.constructed) { + if (saw_payload) { + pdv = {}; + return false; + } + pdv.single_asn1_type = field.value; + saw_payload = true; + } else { + pdv = {}; + return false; + } + } + return saw_context_id && saw_payload; +} + +[[nodiscard]] bool decode_mode_selector( + const asn1::BerTlvView& item, + std::uint32_t& mode) noexcept { + if (item.tag_class != asn1::BerClass::context_specific || + item.tag_number != 0 || !item.constructed) { + return false; + } + asn1::BerTlvView value; + return asn1::BerSpanReader::try_read_exact(item.value, value) && + value.tag_class == asn1::BerClass::context_specific && + value.tag_number == 0 && !value.constructed && + read_positive_integer(value, mode); +} + +[[nodiscard]] std::optional context_results_content_size( + const PresentationCpView& request) noexcept { + std::size_t total = 0U; + std::size_t offset = 0U; + while (offset < request.context_definition_list.size()) { + asn1::BerTlvView definition; + ContextDefinitionView context; + if (!asn1::BerSpanReader::try_read_tlv( + request.context_definition_list, offset, definition) || + !parse_context_definition(definition, context)) { + return std::nullopt; + } + const auto result_tlv = asn1::BerSpanWriter::tlv_size(0, 1U); + const auto syntax_tlv = asn1::BerSpanWriter::tlv_size( + 1, context.first_transfer_syntax.size()); + const auto sequence_content = add_size(result_tlv, syntax_tlv); + if (!sequence_content) { + return std::nullopt; + } + const auto sequence_tlv = asn1::BerSpanWriter::tlv_size( + 16, *sequence_content); + if (!sequence_tlv || *sequence_tlv > + std::numeric_limits::max() - total) { + return std::nullopt; + } + total += *sequence_tlv; + } + return total; +} + +struct CpaSizes final { + std::size_t mode_value{}; + std::size_t mode_inner_tlv{}; + std::size_t mode_outer_tlv{}; + std::size_t results_content{}; + std::size_t results_tlv{}; + std::size_t user_tlv{}; + std::size_t normal_content{}; + std::size_t normal_tlv{}; + std::size_t outer_content{}; + std::size_t outer_tlv{}; +}; + +[[nodiscard]] std::optional calculate_cpa_sizes( + const PresentationCpView& request, + const std::size_t acse_aare_bytes) noexcept { + if (request.mode_selector == 0U || request.context_count == 0U) { + return std::nullopt; + } + + CpaSizes sizes; + sizes.mode_value = unsigned_integer_size(request.mode_selector); + const auto mode_inner = asn1::BerSpanWriter::tlv_size(0, sizes.mode_value); + if (!mode_inner) { + return std::nullopt; + } + const auto mode_outer = asn1::BerSpanWriter::tlv_size(0, *mode_inner); + const auto results_content = context_results_content_size(request); + if (!mode_outer || !results_content) { + return std::nullopt; + } + const auto results_tlv = asn1::BerSpanWriter::tlv_size(5, *results_content); + const auto user_tlv = PresentationSpanCodec::fully_encoded_data_size( + request.user_data.context_id, acse_aare_bytes); + const auto normal_content = add_size(results_tlv, user_tlv); + if (!results_tlv || !user_tlv || !normal_content) { + return std::nullopt; + } + const auto normal_tlv = asn1::BerSpanWriter::tlv_size(2, *normal_content); + const auto outer_content = add_size(mode_outer, normal_tlv); + if (!normal_tlv || !outer_content) { + return std::nullopt; + } + const auto outer_tlv = asn1::BerSpanWriter::tlv_size(17, *outer_content); + if (!outer_tlv || *outer_tlv > PresentationSpanCodec::maximum_ppdu_bytes) { + return std::nullopt; + } + + sizes.mode_inner_tlv = *mode_inner; + sizes.mode_outer_tlv = *mode_outer; + sizes.results_content = *results_content; + sizes.results_tlv = *results_tlv; + sizes.user_tlv = *user_tlv; + sizes.normal_content = *normal_content; + sizes.normal_tlv = *normal_tlv; + sizes.outer_content = *outer_content; + sizes.outer_tlv = *outer_tlv; + return sizes; +} + +} // namespace + +bool PresentationCpView::try_context_id_for_abstract_syntax( + const std::span object_identifier_value, + std::uint32_t& context_id) const noexcept { + return find_context_by_abstract_syntax( + context_definition_list, object_identifier_value, context_id); +} + +std::span +PresentationSpanCodec::acse_abstract_syntax_name() noexcept { + return kAcseAbstractSyntax; +} + +std::span +PresentationSpanCodec::mms_abstract_syntax_name() noexcept { + return kMmsAbstractSyntax; +} + +std::span +PresentationSpanCodec::ber_transfer_syntax_name() noexcept { + return kBerTransferSyntax; +} + +std::optional PresentationSpanCodec::fully_encoded_data_size( + const std::uint32_t context_id, + const std::size_t single_asn1_type_bytes) noexcept { + const auto sizes = calculate_fully_encoded_sizes(context_id, single_asn1_type_bytes); + return sizes ? std::optional{sizes->outer_tlv} : std::nullopt; +} + +wire::EncodeResult PresentationSpanCodec::encode_fully_encoded_data_into( + const std::uint32_t context_id, + const std::span single_asn1_type, + const std::span destination) noexcept { + const auto sizes = calculate_fully_encoded_sizes(context_id, single_asn1_type.size()); + if (!sizes) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < sizes->outer_tlv) { + return {wire::EncodeStatus::buffer_too_small, 0U, sizes->outer_tlv}; + } + + asn1::BerSpanWriter writer{destination.first(sizes->outer_tlv)}; + if (!writer.write_tlv_header( + asn1::BerClass::application, true, 1, sizes->sequence_tlv) || + !writer.write_tlv_header( + asn1::BerClass::universal, true, 16, sizes->sequence_content) || + !writer.write_tlv_header( + asn1::BerClass::universal, false, 2, sizes->id_value) || + !write_unsigned_integer(writer, context_id) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, + true, + 0, + single_asn1_type.size()) || + !writer.write_bytes(single_asn1_type) || + writer.size() != sizes->outer_tlv) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizes->outer_tlv}; + } + return {wire::EncodeStatus::ok, sizes->outer_tlv, sizes->outer_tlv}; +} + +bool PresentationSpanCodec::try_decode_fully_encoded_data_view( + const std::span bytes, + PresentationPdvView& pdv) noexcept { + pdv = {}; + if (bytes.empty() || bytes.size() > maximum_ppdu_bytes) { + return false; + } + asn1::BerTlvView outer; + return asn1::BerSpanReader::try_read_exact(bytes, outer) && + decode_fully_encoded_outer(outer, pdv); +} + +bool PresentationSpanCodec::try_decode_cp_view( + const std::span bytes, + PresentationCpView& cp) noexcept { + cp = {}; + if (bytes.empty() || bytes.size() > maximum_ppdu_bytes) { + return false; + } + + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(bytes, outer) || + outer.tag_class != asn1::BerClass::universal || + outer.tag_number != 17 || !outer.constructed) { + return false; + } + + bool saw_mode = false; + bool saw_normal = false; + std::size_t offset = 0U; + while (offset < outer.value.size()) { + asn1::BerTlvView item; + if (!asn1::BerSpanReader::try_read_tlv(outer.value, offset, item)) { + cp = {}; + return false; + } + + if (item.tag_class == asn1::BerClass::context_specific && + item.tag_number == 0 && item.constructed) { + if (saw_mode || !decode_mode_selector(item, cp.mode_selector)) { + cp = {}; + return false; + } + saw_mode = true; + } else if (item.tag_class == asn1::BerClass::context_specific && + item.tag_number == 2 && item.constructed) { + if (saw_normal) { + cp = {}; + return false; + } + bool saw_calling = false; + bool saw_called = false; + bool saw_contexts = false; + bool saw_user_data = false; + std::size_t normal_offset = 0U; + while (normal_offset < item.value.size()) { + asn1::BerTlvView normal; + if (!asn1::BerSpanReader::try_read_tlv( + item.value, normal_offset, normal)) { + cp = {}; + return false; + } + + if (normal.tag_class == asn1::BerClass::context_specific && + normal.tag_number == 1 && !normal.constructed) { + if (saw_calling || normal.value.size() > maximum_selector_bytes) { + cp = {}; + return false; + } + cp.calling_selector = normal.value; + saw_calling = true; + } else if (normal.tag_class == asn1::BerClass::context_specific && + normal.tag_number == 2 && !normal.constructed) { + if (saw_called || normal.value.size() > maximum_selector_bytes) { + cp = {}; + return false; + } + cp.called_selector = normal.value; + saw_called = true; + } else if (normal.tag_class == asn1::BerClass::context_specific && + normal.tag_number == 4 && normal.constructed) { + if (saw_contexts || !validate_context_list( + normal.value, cp.context_count)) { + cp = {}; + return false; + } + cp.context_definition_list = normal.value; + saw_contexts = true; + } else if (normal.tag_class == asn1::BerClass::application && + normal.tag_number == 1 && normal.constructed) { + if (saw_user_data || !decode_fully_encoded_outer( + normal, cp.user_data)) { + cp = {}; + return false; + } + saw_user_data = true; + } else { + cp = {}; + return false; + } + } + if (!saw_contexts || !saw_user_data || + !context_id_exists( + cp.context_definition_list, cp.user_data.context_id)) { + cp = {}; + return false; + } + saw_normal = true; + } else { + cp = {}; + return false; + } + } + + if (!saw_mode || !saw_normal) { + cp = {}; + return false; + } + return true; +} + +std::optional PresentationSpanCodec::cpa_accepting_size( + const PresentationCpView& request, + const std::size_t acse_aare_bytes) noexcept { + const auto sizes = calculate_cpa_sizes(request, acse_aare_bytes); + return sizes ? std::optional{sizes->outer_tlv} : std::nullopt; +} + +wire::EncodeResult PresentationSpanCodec::encode_cpa_accepting_into( + const PresentationCpView& request, + const std::span acse_aare, + const std::span destination) noexcept { + const auto sizes = calculate_cpa_sizes(request, acse_aare.size()); + if (!sizes) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < sizes->outer_tlv) { + return {wire::EncodeStatus::buffer_too_small, 0U, sizes->outer_tlv}; + } + + asn1::BerSpanWriter writer{destination.first(sizes->outer_tlv)}; + if (!writer.write_tlv_header( + asn1::BerClass::universal, true, 17, sizes->outer_content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, sizes->mode_inner_tlv) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, sizes->mode_value) || + !write_unsigned_integer(writer, request.mode_selector) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 2, sizes->normal_content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 5, sizes->results_content)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizes->outer_tlv}; + } + + std::size_t context_offset = 0U; + while (context_offset < request.context_definition_list.size()) { + asn1::BerTlvView definition; + ContextDefinitionView context; + if (!asn1::BerSpanReader::try_read_tlv( + request.context_definition_list, context_offset, definition) || + !parse_context_definition(definition, context)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizes->outer_tlv}; + } + const auto result_tlv = asn1::BerSpanWriter::tlv_size(0, 1U); + const auto syntax_tlv = asn1::BerSpanWriter::tlv_size( + 1, context.first_transfer_syntax.size()); + const auto sequence_content = add_size(result_tlv, syntax_tlv); + if (!sequence_content || + !writer.write_tlv_header( + asn1::BerClass::universal, true, 16, *sequence_content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, 1U) || + !writer.write_byte(0x00U) || + !writer.write_tlv( + asn1::BerClass::context_specific, + false, + 1, + context.first_transfer_syntax)) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizes->outer_tlv}; + } + } + + const auto user_result = encode_fully_encoded_data_into( + request.user_data.context_id, + acse_aare, + destination.first(sizes->outer_tlv).subspan(writer.size(), sizes->user_tlv)); + if (!user_result.success() || user_result.bytes_written != sizes->user_tlv) { + return {user_result.status, 0U, sizes->outer_tlv}; + } + const auto written = writer.size() + user_result.bytes_written; + if (written != sizes->outer_tlv) { + return {wire::EncodeStatus::value_out_of_range, 0U, sizes->outer_tlv}; + } + return {wire::EncodeStatus::ok, written, sizes->outer_tlv}; +} + +wire::EncodeResult PresentationSpanCodec::encode_p_data_into( + const std::span abstract_syntax_payload, + const std::span destination, + const std::uint32_t presentation_context_id, + const bool include_give_tokens_prefix) noexcept { + const auto fully_encoded = fully_encoded_data_size( + presentation_context_id, abstract_syntax_payload.size()); + if (!fully_encoded) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const auto prefix = include_give_tokens_prefix ? 4U : 2U; + if (*fully_encoded > std::numeric_limits::max() - prefix) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto required = prefix + *fully_encoded; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + + std::size_t output_offset = 0U; + if (include_give_tokens_prefix) { + destination[output_offset++] = SessionSpanCodec::data_transfer_code; + destination[output_offset++] = 0x00U; + } + destination[output_offset++] = SessionSpanCodec::data_transfer_code; + destination[output_offset++] = 0x00U; + + const auto result = encode_fully_encoded_data_into( + presentation_context_id, + abstract_syntax_payload, + destination.subspan(output_offset, *fully_encoded)); + if (!result.success() || result.bytes_written != *fully_encoded) { + return {result.status, 0U, required}; + } + return {wire::EncodeStatus::ok, required, required}; +} + +bool PresentationSpanCodec::try_decode_p_data_view( + const std::span bytes, + PresentationPdvView& pdv) noexcept { + pdv = {}; + std::span presentation = bytes; + SessionDataTransferView transfer; + if (!bytes.empty() && bytes.front() == SessionSpanCodec::data_transfer_code) { + if (!SessionSpanCodec::try_decode_data_transfer_view(bytes, transfer)) { + return false; + } + presentation = transfer.presentation_payload; + } + return try_decode_fully_encoded_data_view(presentation, pdv); +} + +} // namespace ar::iec61850::osi diff --git a/src/osi/session_span.cpp b/src/osi/session_span.cpp new file mode 100644 index 000000000..be0dcc10e --- /dev/null +++ b/src/osi/session_span.cpp @@ -0,0 +1,346 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/osi/session_span.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::osi { +namespace { + +constexpr std::array kParameter05{ + 0x13U, 0x01U, 0x00U, 0x16U, 0x01U, 0x02U}; +constexpr std::array kParameter14{0x00U, 0x02U}; +constexpr std::array kParameter33{0x00U, 0x01U}; +constexpr std::array kParameter34{0x00U, 0x01U}; + +const std::array kDefaultParameters{ + SessionParameterView{0x05U, kParameter05}, + SessionParameterView{0x14U, kParameter14}, + SessionParameterView{0x33U, kParameter33}, + SessionParameterView{0x34U, kParameter34}, +}; + +[[nodiscard]] bool encoded_body_size( + const std::span parameters, + const std::span user_data, + std::size_t& body_length) noexcept { + body_length = 0U; + if (parameters.size() > SessionSpanCodec::maximum_parameter_count || + user_data.size() > std::numeric_limits::max()) { + return false; + } + + if (!user_data.empty()) { + const auto encoded_user = user_data.size() + 2U; + if (encoded_user > std::numeric_limits::max()) { + return false; + } + body_length = encoded_user; + } + for (const auto& parameter : parameters) { + if (parameter.code == SessionSpanCodec::user_data_parameter || + parameter.value.size() > std::numeric_limits::max()) { + body_length = 0U; + return false; + } + const auto encoded = parameter.value.size() + 2U; + if (body_length > std::numeric_limits::max() || + encoded > std::numeric_limits::max() - body_length) { + body_length = 0U; + return false; + } + body_length += encoded; + } + return body_length <= std::numeric_limits::max(); +} + +[[nodiscard]] bool write_parameter( + const std::span destination, + std::size_t& offset, + const std::uint8_t code, + const std::span value) noexcept { + if (offset > destination.size() || + value.size() > std::numeric_limits::max() || + value.size() + 2U > destination.size() - offset) { + return false; + } + destination[offset++] = code; + destination[offset++] = static_cast(value.size()); + std::copy( + value.begin(), + value.end(), + destination.begin() + static_cast(offset)); + offset += value.size(); + return true; +} + +} // namespace + +bool SessionSpduView::try_parameter( + const std::uint8_t parameter_code, + std::span& value) const noexcept { + value = {}; + std::size_t offset = 0U; + while (offset < parameter_bytes.size()) { + if (parameter_bytes.size() - offset < 2U) { + return false; + } + const auto code = parameter_bytes[offset]; + const auto length = static_cast(parameter_bytes[offset + 1U]); + offset += 2U; + if (length > parameter_bytes.size() - offset) { + return false; + } + if (code == parameter_code) { + value = parameter_bytes.subspan(offset, length); + return true; + } + offset += length; + } + return false; +} + +SessionWireKind SessionSpanCodec::kind_from_code(const std::uint8_t code) noexcept { + switch (code) { + case connect_code: + return SessionWireKind::connect; + case accept_code: + return SessionWireKind::accept; + case reject_code: + return SessionWireKind::reject; + case refuse_code: + return SessionWireKind::refuse; + case abort_code: + return SessionWireKind::abort; + case data_transfer_code: + return SessionWireKind::data_transfer; + default: + return SessionWireKind::unknown; + } +} + +std::span SessionSpanCodec::default_parameters() noexcept { + return kDefaultParameters; +} + +bool SessionSpanCodec::try_decode_prefix_view( + const std::span bytes, + SessionSpduView& spdu, + std::size_t& consumed) noexcept { + spdu = {}; + consumed = 0U; + if (bytes.size() < 2U) { + return false; + } + + const auto kind = kind_from_code(bytes[0]); + if (kind == SessionWireKind::unknown) { + return false; + } + + const auto body_length = static_cast(bytes[1]); + const auto total_length = body_length + 2U; + if (total_length > maximum_spdu_bytes || total_length > bytes.size()) { + return false; + } + + std::size_t offset = 2U; + std::size_t parameter_end = total_length; + std::size_t parameter_count = 0U; + bool saw_user_data = false; + std::span user_data; + + while (offset < total_length) { + if (parameter_count >= maximum_parameter_count || + total_length - offset < 2U) { + return false; + } + ++parameter_count; + const auto parameter_start = offset; + const auto code = bytes[offset++]; + const auto length = static_cast(bytes[offset++]); + if (length > total_length - offset) { + return false; + } + + if (code == user_data_parameter) { + if (saw_user_data || offset + length != total_length) { + return false; + } + saw_user_data = true; + parameter_end = parameter_start; + user_data = bytes.subspan(offset, length); + } + offset += length; + } + + spdu.kind = kind; + spdu.code = bytes[0]; + spdu.length_indicator = bytes[1]; + spdu.parameter_bytes = bytes.subspan(2U, parameter_end - 2U); + spdu.user_data = user_data; + consumed = total_length; + return true; +} + +bool SessionSpanCodec::try_decode_view( + const std::span bytes, + SessionSpduView& spdu) noexcept { + std::size_t consumed = 0U; + return try_decode_prefix_view(bytes, spdu, consumed) && consumed == bytes.size(); +} + +wire::EncodeResult SessionSpanCodec::encode_into( + const std::uint8_t code, + const std::span parameters, + const std::span user_data, + const std::span destination) noexcept { + if (kind_from_code(code) == SessionWireKind::unknown) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + std::size_t body_length{}; + if (!encoded_body_size(parameters, user_data, body_length)) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto required = body_length + 2U; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + + destination[0] = code; + destination[1] = static_cast(body_length); + std::size_t offset = 2U; + for (const auto& parameter : parameters) { + if (!write_parameter(destination, offset, parameter.code, parameter.value)) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + } + if (!user_data.empty() && + !write_parameter(destination, offset, user_data_parameter, user_data)) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (offset != required) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + return {wire::EncodeStatus::ok, required, required}; +} + +wire::EncodeResult SessionSpanCodec::encode_connect_into( + const std::span presentation_payload, + const std::span destination, + const std::span parameters) noexcept { + const auto effective = parameters.empty() ? default_parameters() : parameters; + return encode_into(connect_code, effective, presentation_payload, destination); +} + +wire::EncodeResult SessionSpanCodec::encode_accept_into( + const std::span presentation_payload, + const std::span destination, + const std::span parameters) noexcept { + const auto effective = parameters.empty() ? default_parameters() : parameters; + return encode_into(accept_code, effective, presentation_payload, destination); +} + +wire::EncodeResult SessionSpanCodec::encode_accept_mirroring_into( + const SessionSpduView& connect, + const std::span presentation_payload, + const std::span destination) noexcept { + if (connect.kind != SessionWireKind::connect || + presentation_payload.size() > std::numeric_limits::max()) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const auto user_bytes = presentation_payload.empty() + ? 0U + : presentation_payload.size() + 2U; + if (user_bytes > std::numeric_limits::max() || + connect.parameter_bytes.size() > + std::numeric_limits::max() - user_bytes) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto body_length = connect.parameter_bytes.size() + user_bytes; + const auto required = body_length + 2U; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + + destination[0] = accept_code; + destination[1] = static_cast(body_length); + std::copy( + connect.parameter_bytes.begin(), + connect.parameter_bytes.end(), + destination.begin() + 2); + std::size_t offset = 2U + connect.parameter_bytes.size(); + if (!presentation_payload.empty() && + !write_parameter( + destination, + offset, + user_data_parameter, + presentation_payload)) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + return offset == required + ? wire::EncodeResult{wire::EncodeStatus::ok, required, required} + : wire::EncodeResult{wire::EncodeStatus::value_out_of_range, 0U, required}; +} + +wire::EncodeResult SessionSpanCodec::encode_data_transfer_into( + const std::span presentation_payload, + const std::span destination, + const bool include_give_tokens_prefix) noexcept { + const auto prefix = include_give_tokens_prefix ? 4U : 2U; + if (presentation_payload.size() > + std::numeric_limits::max() - prefix) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto required = presentation_payload.size() + prefix; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + + std::size_t offset = 0U; + if (include_give_tokens_prefix) { + destination[offset++] = data_transfer_code; + destination[offset++] = 0x00U; + } + destination[offset++] = data_transfer_code; + destination[offset++] = 0x00U; + std::copy( + presentation_payload.begin(), + presentation_payload.end(), + destination.begin() + static_cast(offset)); + return {wire::EncodeStatus::ok, required, required}; +} + +bool SessionSpanCodec::try_decode_data_transfer_view( + const std::span bytes, + SessionDataTransferView& transfer) noexcept { + transfer = {}; + if (bytes.size() < 2U) { + return false; + } + + std::size_t offset = 0U; + if (bytes.size() >= 4U && + bytes[0] == data_transfer_code && bytes[1] == 0x00U && + bytes[2] == data_transfer_code && bytes[3] == 0x00U) { + transfer.has_give_tokens_prefix = true; + offset = 4U; + } else if (bytes[0] == data_transfer_code && bytes[1] == 0x00U) { + transfer.has_give_tokens_prefix = false; + offset = 2U; + } else { + return false; + } + + transfer.presentation_payload = bytes.subspan(offset); + return true; +} + +} // namespace ar::iec61850::osi diff --git a/src/osi/tpkt_span.cpp b/src/osi/tpkt_span.cpp new file mode 100644 index 000000000..aefec4b99 --- /dev/null +++ b/src/osi/tpkt_span.cpp @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/osi/tpkt_span.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::osi { +namespace { + +[[nodiscard]] std::uint16_t read_be_u16( + const std::span bytes, + const std::size_t offset) noexcept { + return static_cast( + (static_cast(bytes[offset]) << 8U) | + static_cast(bytes[offset + 1U])); +} + +void write_be_u16( + const std::span bytes, + const std::size_t offset, + const std::uint16_t value) noexcept { + bytes[offset] = static_cast((value >> 8U) & 0xFFU); + bytes[offset + 1U] = static_cast(value & 0xFFU); +} + +} // namespace + +wire::EncodeResult TpktSpanCodec::encode_into( + const std::span payload, + const std::span destination) noexcept { + if (payload.size() > maximum_payload_bytes) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const auto required = payload.size() + header_length; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + + destination[0] = supported_version; + destination[1] = 0x00U; + write_be_u16(destination, 2U, static_cast(required)); + std::copy( + payload.begin(), + payload.end(), + destination.begin() + static_cast(header_length)); + return {wire::EncodeStatus::ok, required, required}; +} + +TpktPeekResult TpktSpanCodec::peek_frame( + const std::span bytes) noexcept { + if (bytes.size() < header_length) { + return {TpktPeekStatus::need_more, 0U}; + } + if (bytes[0] != supported_version || bytes[1] != 0x00U) { + return {TpktPeekStatus::invalid, 0U}; + } + + const auto declared = static_cast(read_be_u16(bytes, 2U)); + if (declared < header_length) { + return {TpktPeekStatus::invalid, 0U}; + } + if (bytes.size() < declared) { + return {TpktPeekStatus::need_more, declared}; + } + return {TpktPeekStatus::ready, declared}; +} + +bool TpktSpanCodec::try_decode_view( + const std::span bytes, + TpktFrameView& frame) noexcept { + frame = {}; + const auto peek = peek_frame(bytes); + if (!peek.ready() || peek.frame_bytes != bytes.size()) { + return false; + } + + frame.version = bytes[0]; + frame.declared_length = static_cast(peek.frame_bytes); + frame.payload = bytes.subspan(header_length); + return true; +} + +} // namespace ar::iec61850::osi diff --git a/src/sampled_values/frame_codec.cpp b/src/sampled_values/frame_codec.cpp index 80f62e830..d5f70fe51 100644 --- a/src/sampled_values/frame_codec.cpp +++ b/src/sampled_values/frame_codec.cpp @@ -11,9 +11,11 @@ #include #include #include +#if !defined(ARIEC61850_NO_EXCEPTIONS) #include #include #include +#endif namespace ar::iec61850::sampled_values { namespace { @@ -117,6 +119,7 @@ wire::EncodeResult SampledValuesFrameCodec::encode_into( return {wire::EncodeStatus::ok, offset, *required}; } +#if !defined(ARIEC61850_NO_EXCEPTIONS) std::vector SampledValuesFrameCodec::encode( const SampledValuesFrame& frame) { const auto required = encoded_size(frame); @@ -163,5 +166,6 @@ bool SampledValuesFrameCodec::try_decode( frame.pdu = std::move(pdu); return true; } +#endif } // namespace ar::iec61850::sampled_values diff --git a/src/sampled_values/pdu_codec.cpp b/src/sampled_values/pdu_codec.cpp index 253b624b6..3cc037d29 100644 --- a/src/sampled_values/pdu_codec.cpp +++ b/src/sampled_values/pdu_codec.cpp @@ -2,7 +2,9 @@ #include "ariec61850/sampled_values/pdu_codec.hpp" +#if !defined(ARIEC61850_NO_EXCEPTIONS) #include "ariec61850/asn1/ber.hpp" +#endif #include "ariec61850/mms/utc_time.hpp" #include @@ -12,10 +14,12 @@ #include #include #include -#include #include +#if !defined(ARIEC61850_NO_EXCEPTIONS) +#include #include #include +#endif namespace ar::iec61850::sampled_values { namespace { @@ -266,6 +270,7 @@ void write_unsigned( return true; } +#if !defined(ARIEC61850_NO_EXCEPTIONS) bool read_unsigned_exact( const asn1::BerTlv& field, const std::uint64_t maximum, @@ -360,6 +365,7 @@ bool read_asdu_sequence( } return true; } +#endif } // namespace @@ -428,6 +434,7 @@ wire::EncodeResult SampledValuesPduCodec::encode_into( return {wire::EncodeStatus::ok, offset, *required}; } +#if !defined(ARIEC61850_NO_EXCEPTIONS) std::vector SampledValuesPduCodec::encode( const SampledValuesPdu& pdu) { const auto required = encoded_size(pdu); @@ -495,5 +502,6 @@ bool SampledValuesPduCodec::try_decode( return false; } } +#endif } // namespace ar::iec61850::sampled_values diff --git a/tools/device_ctl/CMakeLists.txt b/tools/device_ctl/CMakeLists.txt new file mode 100644 index 000000000..8a769e07b --- /dev/null +++ b/tools/device_ctl/CMakeLists.txt @@ -0,0 +1,21 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARStack61850SvDeviceCtl + VERSION 0.1.0 + DESCRIPTION "Windows USB serial control client for the ESP32-P4 SV injector" + LANGUAGES CXX) + +add_executable(ariec61850_sv_device_ctl + ${CMAKE_CURRENT_LIST_DIR}/../sv_device_ctl.cpp) + +target_compile_features(ariec61850_sv_device_ctl PRIVATE cxx_std_20) +target_include_directories(ariec61850_sv_device_ctl PRIVATE + ${CMAKE_CURRENT_LIST_DIR}/..) + +if(MSVC) + target_compile_options(ariec61850_sv_device_ctl PRIVATE + /W4 /WX /permissive-) +else() + target_compile_options(ariec61850_sv_device_ctl PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) +endif() diff --git a/tools/npcap_live.hpp b/tools/npcap_live.hpp new file mode 100644 index 000000000..c2d9de2a9 --- /dev/null +++ b/tools/npcap_live.hpp @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/embedded/io.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#endif + +namespace ar::iec61850::tools { + +struct LiveEthernetAdapter final { + std::string name; + std::string description; +}; + +class NpcapLivePort final { +public: + NpcapLivePort() noexcept { +#ifdef _WIN32 + load(); +#else + error_ = "Npcap live mode is available only on Windows."; +#endif + } + + ~NpcapLivePort() { + close(); +#ifdef _WIN32 + unload(); +#endif + } + + NpcapLivePort(const NpcapLivePort&) = delete; + NpcapLivePort& operator=(const NpcapLivePort&) = delete; + NpcapLivePort(NpcapLivePort&&) = delete; + NpcapLivePort& operator=(NpcapLivePort&&) = delete; + + [[nodiscard]] bool runtime_available() const noexcept { +#ifdef _WIN32 + return module_ != nullptr && find_all_devs_ != nullptr && + free_all_devs_ != nullptr && open_live_ != nullptr && + send_packet_ != nullptr && get_error_ != nullptr && close_ != nullptr; +#else + return false; +#endif + } + + [[nodiscard]] bool opened() const noexcept { +#ifdef _WIN32 + return handle_ != nullptr; +#else + return false; +#endif + } + + [[nodiscard]] const std::string& error() const noexcept { return error_; } + [[nodiscard]] const std::string& opened_name() const noexcept { return opened_name_; } + [[nodiscard]] const std::string& opened_description() const noexcept { + return opened_description_; + } + + [[nodiscard]] bool list_adapters(std::vector& adapters) { + adapters.clear(); +#ifdef _WIN32 + if (!runtime_available()) { + return false; + } + + char error_buffer[kErrorBufferSize]{}; + PcapIf* devices = nullptr; + if (find_all_devs_(&devices, error_buffer) != 0) { + error_ = error_buffer[0] == '\0' + ? "Npcap could not enumerate adapters." + : std::string{error_buffer}; + return false; + } + + for (auto* current = devices; current != nullptr; current = current->next) { + if (current->name == nullptr) { + continue; + } + adapters.push_back({ + current->name, + current->description == nullptr ? std::string{} : current->description}); + } + free_all_devs_(devices); + error_.clear(); + return true; +#else + return false; +#endif + } + + [[nodiscard]] bool open(const std::string_view selector) { +#ifdef _WIN32 + close(); + if (!runtime_available()) { + return false; + } + if (selector.empty()) { + error_ = "Live mode requires --interface NAME."; + return false; + } + + std::vector adapters; + if (!list_adapters(adapters)) { + return false; + } + + const auto selector_lower = lower(selector); + std::vector partial_matches; + const LiveEthernetAdapter* selected = nullptr; + for (const auto& adapter : adapters) { + if (adapter.name == selector) { + selected = &adapter; + break; + } + const auto name_lower = lower(adapter.name); + const auto description_lower = lower(adapter.description); + if (name_lower.find(selector_lower) != std::string::npos || + description_lower.find(selector_lower) != std::string::npos) { + partial_matches.push_back(&adapter); + } + } + + if (selected == nullptr) { + if (partial_matches.size() == 1U) { + selected = partial_matches.front(); + } else if (partial_matches.empty()) { + error_ = "No Npcap adapter matched --interface '" + + std::string{selector} + "'. Use --list-interfaces."; + return false; + } else { + error_ = "The --interface selector '" + std::string{selector} + + "' is ambiguous. Use the exact Npcap device name from --list-interfaces."; + return false; + } + } + + char error_buffer[kErrorBufferSize]{}; + handle_ = open_live_(selected->name.c_str(), 65'536, 1, 1, error_buffer); + if (handle_ == nullptr) { + error_ = error_buffer[0] == '\0' + ? "Npcap could not open the selected adapter." + : std::string{error_buffer}; + return false; + } + + opened_name_ = selected->name; + opened_description_ = selected->description; + error_.clear(); + return true; +#else + (void)selector; + return false; +#endif + } + + void close() noexcept { +#ifdef _WIN32 + if (handle_ != nullptr && close_ != nullptr) { + close_(handle_); + } + handle_ = nullptr; +#endif + opened_name_.clear(); + opened_description_.clear(); + } + + [[nodiscard]] embedded::RawEthernetPort raw_port() noexcept { + return {this, &transmit_callback}; + } + +private: +#ifdef _WIN32 + struct Pcap; + struct PcapAddr; + struct PcapIf final { + PcapIf* next; + char* name; + char* description; + PcapAddr* addresses; + unsigned int flags; + }; + + static constexpr std::size_t kErrorBufferSize = 256U; + + using FindAllDevs = int(__cdecl*)(PcapIf**, char*); + using FreeAllDevs = void(__cdecl*)(PcapIf*); + using OpenLive = Pcap*(__cdecl*)(const char*, int, int, int, char*); + using SendPacket = int(__cdecl*)(Pcap*, const unsigned char*, int); + using GetError = const char*(__cdecl*)(Pcap*); + using Close = void(__cdecl*)(Pcap*); + + template + [[nodiscard]] Function symbol(const char* name) noexcept { + const auto raw = GetProcAddress(module_, name); + return std::bit_cast(raw); + } + + void load() noexcept { + module_ = LoadLibraryA("wpcap.dll"); + if (module_ == nullptr) { + error_ = "wpcap.dll was not found. Install Npcap, then retry."; + return; + } + + find_all_devs_ = symbol("pcap_findalldevs"); + free_all_devs_ = symbol("pcap_freealldevs"); + open_live_ = symbol("pcap_open_live"); + send_packet_ = symbol("pcap_sendpacket"); + get_error_ = symbol("pcap_geterr"); + close_ = symbol("pcap_close"); + + if (!runtime_available()) { + error_ = "wpcap.dll is present but required Npcap symbols are missing."; + unload(); + return; + } + error_.clear(); + } + + void unload() noexcept { + if (module_ != nullptr) { + FreeLibrary(module_); + } + module_ = nullptr; + find_all_devs_ = nullptr; + free_all_devs_ = nullptr; + open_live_ = nullptr; + send_packet_ = nullptr; + get_error_ = nullptr; + close_ = nullptr; + } + + [[nodiscard]] static std::string lower(const std::string_view value) { + std::string result{value}; + std::transform( + result.begin(), result.end(), result.begin(), + [](const char character) { + return static_cast( + std::tolower(static_cast(character))); + }); + return result; + } +#endif + + static embedded::IoResult transmit_callback( + void* context, + const std::span bytes) noexcept { + auto* self = static_cast(context); + if (self == nullptr || bytes.empty()) { + return {embedded::IoStatus::invalid_argument, 0U}; + } +#ifdef _WIN32 + if (!self->opened() || self->send_packet_ == nullptr || + bytes.size() > static_cast(std::numeric_limits::max())) { + return {embedded::IoStatus::io_error, 0U}; + } + + const auto result = self->send_packet_( + self->handle_, bytes.data(), static_cast(bytes.size())); + if (result != 0) { + const auto* message = self->get_error_ == nullptr + ? nullptr + : self->get_error_(self->handle_); + self->error_ = message == nullptr || message[0] == '\0' + ? "Npcap pcap_sendpacket failed." + : std::string{message}; + return {embedded::IoStatus::io_error, 0U}; + } + return {embedded::IoStatus::ok, bytes.size()}; +#else + return {embedded::IoStatus::io_error, 0U}; +#endif + } + + std::string error_; + std::string opened_name_; + std::string opened_description_; + +#ifdef _WIN32 + HMODULE module_{}; + Pcap* handle_{}; + FindAllDevs find_all_devs_{}; + FreeAllDevs free_all_devs_{}; + OpenLive open_live_{}; + SendPacket send_packet_{}; + GetError get_error_{}; + Close close_{}; +#endif +}; + +} // namespace ar::iec61850::tools diff --git a/tools/replay_prepare/CMakeLists.txt b/tools/replay_prepare/CMakeLists.txt new file mode 100644 index 000000000..8b06da000 --- /dev/null +++ b/tools/replay_prepare/CMakeLists.txt @@ -0,0 +1,32 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARStack61850SvReplayTools LANGUAGES CXX) + +set(ARIEC61850_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(ARIEC61850_BUILD_TOOLS OFF CACHE BOOL "" FORCE) +set(ARIEC61850_BUILD_FUZZERS OFF CACHE BOOL "" FORCE) +set(ARIEC61850_ENABLE_SANITIZERS OFF CACHE BOOL "" FORCE) + +add_subdirectory( + "${CMAKE_CURRENT_LIST_DIR}/../.." + "${CMAKE_CURRENT_BINARY_DIR}/arstack-core" + EXCLUDE_FROM_ALL) + +function(arstack_replay_tool target source) + add_executable(${target} "${source}") + target_link_libraries(${target} PRIVATE ARIEC61850::core) + target_compile_features(${target} PRIVATE cxx_std_20) + if(MSVC) + target_compile_options(${target} PRIVATE /W4 /permissive- /WX) + else() + target_compile_options(${target} PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) + endif() +endfunction() + +arstack_replay_tool( + ariec61850_sv_replay_prepare + "${CMAKE_CURRENT_LIST_DIR}/../sv_replay_prepare.cpp") +arstack_replay_tool( + ariec61850_sv_replay_live + "${CMAKE_CURRENT_LIST_DIR}/../sv_replay_live.cpp") diff --git a/tools/sv_device_ctl.cpp b/tools/sv_device_ctl.cpp new file mode 100644 index 000000000..1ddc6bfbb --- /dev/null +++ b/tools/sv_device_ctl.cpp @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "windows_serial_control.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using ar::iec61850::tools::WindowsSerialControlPort; + +struct Options final { + std::string device; + std::vector positional; + std::uint32_t timeout_ms{3'000U}; + bool emit_request{}; + bool help{}; +}; + +[[nodiscard]] std::uint32_t parse_timeout(const std::string& value) { + std::size_t consumed = 0U; + const auto parsed = std::stoul(value, &consumed, 10); + if (consumed != value.size() || parsed < 100U || parsed > 60'000U) { + throw std::invalid_argument("--timeout-ms requires 100..60000."); + } + return static_cast(parsed); +} + +[[nodiscard]] Options parse_options(const int argc, char** argv) { + Options options; + for (int index = 1; index < argc; ++index) { + const std::string argument = argv[index]; + auto require_value = [&](const std::string_view option) -> std::string { + if (index + 1 >= argc) { + throw std::invalid_argument(std::string{option} + " requires a value."); + } + ++index; + return argv[index]; + }; + + if (argument == "--device") { + options.device = require_value("--device"); + } else if (argument == "--timeout-ms") { + options.timeout_ms = parse_timeout(require_value("--timeout-ms")); + } else if (argument == "--emit-request") { + options.emit_request = true; + } else if (argument == "--help" || argument == "-h") { + options.help = true; + } else { + options.positional.push_back(argument); + } + } + + if (!options.help && options.positional.empty()) { + throw std::invalid_argument("A device command is required."); + } + if (!options.help && !options.emit_request && options.device.empty()) { + throw std::invalid_argument("--device COMx is required unless --emit-request is used."); + } + return options; +} + +void print_usage() { + std::cout + << "Usage: ariec61850_sv_device_ctl --device COMx [args] [options]\n\n" + << "Control an ESP32-P4 ARStack61850 SV injector over USB Serial/JTAG.\n\n" + << "Lifecycle commands:\n" + << " capabilities\n" + << " status\n" + << " configure normal\n" + << " configure protection-fault\n" + << " arm\n" + << " start\n" + << " stop\n" + << " stats\n\n" + << "Realtime source commands:\n" + << " set-channel \n" + << " ramp-channel \n\n" + << "Transactional sequence commands:\n" + << " sequence-begin\n" + << " sequence-state-begin [step|linear]\n" + << " sequence-set-channel \n" + << " sequence-state-commit\n" + << " sequence-commit\n" + << " sequence-abort\n\n" + << "Channels: Ia Ib Ic In Va Vb Vc Vn\n" + << "Fields: enabled rms dc phase frequency harmonic harmonic-order clip quality\n" + << " phase = millidegrees\n" + << " frequency = millihertz\n" + << " harmonic = permyriad\n" + << " clip = permyriad\n\n" + << "Options:\n" + << " --device COMx Windows COM port for ESP32-P4 USB Serial/JTAG.\n" + << " --timeout-ms N Response timeout, 100..60000 (default 3000).\n" + << " --emit-request Print request JSON only; do not open a device.\n" + << " --help Show this help.\n\n" + << "Examples:\n" + << " ariec61850_sv_device_ctl --device COM7 status\n" + << " ariec61850_sv_device_ctl --device COM7 arm\n" + << " ariec61850_sv_device_ctl --device COM7 start\n" + << " ariec61850_sv_device_ctl --device COM7 set-channel Ia rms 2500\n" + << " ariec61850_sv_device_ctl --device COM7 set-channel Va phase 15000\n" + << " ariec61850_sv_device_ctl --device COM7 ramp-channel Ia rms 5000 4000\n" + << " ariec61850_sv_device_ctl --device COM7 sequence-begin\n" + << " ariec61850_sv_device_ctl --device COM7 sequence-state-begin 2000 step\n" + << " ariec61850_sv_device_ctl --device COM7 sequence-set-channel Ia rms 4000\n" + << " ariec61850_sv_device_ctl --device COM7 sequence-state-commit\n" + << " ariec61850_sv_device_ctl --device COM7 sequence-commit\n" + << " ariec61850_sv_device_ctl --device COM7 stop\n"; +} + +[[nodiscard]] bool valid_channel(const std::string_view channel) noexcept { + return channel == "Ia" || channel == "Ib" || channel == "Ic" || channel == "In" || + channel == "Va" || channel == "Vb" || channel == "Vc" || channel == "Vn"; +} + +[[nodiscard]] std::string protocol_field(const std::string_view field) { + if (field == "enabled") { + return "enabled"; + } + if (field == "rms") { + return "rms"; + } + if (field == "dc") { + return "dc"; + } + if (field == "phase") { + return "phaseMilliDeg"; + } + if (field == "frequency") { + return "frequencyMilliHz"; + } + if (field == "harmonic") { + return "harmonicPermyriad"; + } + if (field == "harmonic-order") { + return "harmonicOrder"; + } + if (field == "clip") { + return "clipPermyriad"; + } + if (field == "quality") { + return "quality"; + } + throw std::invalid_argument( + "Unknown field. Use enabled, rms, dc, phase, frequency, harmonic, " + "harmonic-order, clip, or quality."); +} + +void validate_integer_text(const std::string& value, const std::string_view name) { + if (value.empty()) { + throw std::invalid_argument(std::string{name} + " requires an integer."); + } + std::size_t cursor = value.front() == '-' ? 1U : 0U; + if (cursor == value.size()) { + throw std::invalid_argument(std::string{name} + " requires an integer."); + } + for (; cursor < value.size(); ++cursor) { + if (value[cursor] < '0' || value[cursor] > '9') { + throw std::invalid_argument(std::string{name} + " requires an integer."); + } + } +} + +[[nodiscard]] std::string channel_edit_request( + const std::string& command, + const std::vector& arguments, + const bool include_duration) { + const auto expected = include_duration ? 5U : 4U; + if (arguments.size() != expected) { + throw std::invalid_argument( + include_duration + ? command + " requires ." + : command + " requires ."); + } + if (!valid_channel(arguments[1])) { + throw std::invalid_argument("Channel must be Ia, Ib, Ic, In, Va, Vb, Vc, or Vn."); + } + const auto field = protocol_field(arguments[2]); + validate_integer_text(arguments[3], "value"); + + auto request = "{\"command\":\"" + command + "\",\"channel\":\"" + + arguments[1] + "\",\"" + field + "\":" + arguments[3]; + if (include_duration) { + validate_integer_text(arguments[4], "durationSamples"); + request += ",\"durationSamples\":" + arguments[4]; + } + request += '}'; + return request; +} + +[[nodiscard]] std::string make_request(const Options& options) { + const auto& arguments = options.positional; + const auto& command = arguments.front(); + + if (command == "capabilities" || command == "status" || + command == "arm" || command == "start" || + command == "stop" || command == "stats" || + command == "sequence-begin" || command == "sequence-state-commit" || + command == "sequence-commit" || command == "sequence-abort") { + if (arguments.size() != 1U) { + throw std::invalid_argument(command + " does not accept positional arguments."); + } + return "{\"command\":\"" + command + "\"}"; + } + + if (command == "configure") { + if (arguments.size() != 2U || + (arguments[1] != "normal" && arguments[1] != "protection-fault")) { + throw std::invalid_argument( + "configure requires scenario normal or protection-fault."); + } + return "{\"command\":\"configure\",\"scenario\":\"" + + arguments[1] + "\"}"; + } + + if (command == "set-channel") { + return channel_edit_request(command, arguments, false); + } + if (command == "ramp-channel") { + return channel_edit_request(command, arguments, true); + } + if (command == "sequence-set-channel") { + return channel_edit_request(command, arguments, false); + } + + if (command == "sequence-state-begin") { + if (arguments.size() != 2U && arguments.size() != 3U) { + throw std::invalid_argument( + "sequence-state-begin requires [step|linear]."); + } + validate_integer_text(arguments[1], "durationSamples"); + const auto transition = arguments.size() == 3U ? arguments[2] : "step"; + if (transition != "step" && transition != "linear") { + throw std::invalid_argument("transition must be step or linear."); + } + return "{\"command\":\"sequence-state-begin\",\"durationSamples\":" + + arguments[1] + ",\"transition\":\"" + transition + "\"}"; + } + + throw std::invalid_argument( + "Unsupported command. Use lifecycle, realtime source, or sequence commands from --help."); +} + +} // namespace + +int main(const int argc, char** argv) { + try { + const auto options = parse_options(argc, argv); + if (options.help) { + print_usage(); + return 0; + } + + const auto request = make_request(options); + if (options.emit_request) { + std::cout << request << '\n'; + return 0; + } + + WindowsSerialControlPort serial; + if (!serial.open(options.device)) { + throw std::runtime_error(serial.error()); + } + if (!serial.write_line(request)) { + throw std::runtime_error(serial.error()); + } + + std::string response; + if (!serial.read_arctrl_line(response, options.timeout_ms)) { + throw std::runtime_error(serial.error()); + } + + // Output only the JSON object. This keeps the executable easy to consume + // from PowerShell, a future GUI, or automated test tooling. + std::cout << response << '\n'; + return response.find("\"ok\":true") != std::string::npos ? 0 : 2; + } catch (const std::exception& error) { + std::cerr << "ariec61850_sv_device_ctl: " << error.what() << '\n'; + return 1; + } +} diff --git a/tools/sv_injector.cpp b/tools/sv_injector.cpp new file mode 100644 index 000000000..5d11e1ada --- /dev/null +++ b/tools/sv_injector.cpp @@ -0,0 +1,884 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/capture/pcap.hpp" +#include "ariec61850/embedded/io.hpp" +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/sampled_values/deterministic_injector.hpp" +#include "ariec61850/sampled_values/frame.hpp" +#include "ariec61850/sampled_values/frame_codec.hpp" +#include "ariec61850/sampled_values/injector_presets.hpp" +#include "ariec61850/sampled_values/payload_writer.hpp" +#include "ariec61850/sampled_values/publisher.hpp" +#include "npcap_live.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::uint32_t kSampleRateHz = 4'000U; +constexpr std::uint64_t kIntervalUs = 250U; +constexpr std::uint16_t kSampleCountWrap = 4'000U; +constexpr std::uint64_t kVirtualStartUs = 1'000'000U; +constexpr std::size_t kPayloadBytes = + sampled_values::injector_channel_count * + sampled_values::SampledValuesPayloadWriter::int32_quality_pair_bytes; + +const ethernet::MacAddress kDestination{ + std::array{0x01U, 0x0CU, 0xCDU, 0x04U, 0x00U, 0x01U}}; +const ethernet::MacAddress kSource{ + std::array{0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x02U}}; + +#ifdef _WIN32 +volatile std::sig_atomic_t gStopRequested = 0; + +void handle_interrupt(const int signal) noexcept { + if (signal == SIGINT) { + gStopRequested = 1; + } +} +#endif + +enum class RunMode : std::uint8_t { + loopback, + live, +}; + +struct Options final { + RunMode mode{RunMode::loopback}; + std::string scenario{"normal"}; + std::optional frames; + std::uint64_t drop_every{}; + std::string pcap_path; + std::string interface_selector; + bool continuous{}; + bool list_interfaces{}; + bool json{}; + bool capabilities{}; + bool help{}; +}; + +struct ScenarioDefinition final { + std::vector segments; + std::uint64_t default_frames{}; + bool loop{true}; +}; + +struct LoopbackEvidence final { + std::uint64_t tx_frames{}; + std::uint64_t rx_frames{}; + std::uint64_t injected_drops{}; + std::uint64_t decode_errors{}; + std::uint64_t identity_errors{}; + std::uint64_t payload_errors{}; + std::uint64_t sample_count_errors{}; + std::uint64_t gap_events{}; + std::uint64_t pcap_errors{}; +}; + +struct LoopbackContext final { + LoopbackEvidence evidence{}; + std::uint64_t drop_every{}; + std::uint16_t expected_sample_count{}; + std::array expected_payload{}; + std::optional previous_received_count; + std::uint64_t virtual_timestamp_us{}; + capture::PcapWriter* pcap_writer{}; +}; + +[[nodiscard]] std::uint64_t parse_bounded_u64( + const std::string_view option, + const std::string& value, + const std::uint64_t minimum, + const std::uint64_t maximum) { + std::size_t consumed = 0U; + const auto parsed = std::stoull(value, &consumed, 10); + if (consumed != value.size() || parsed < minimum || parsed > maximum) { + throw std::invalid_argument( + std::string{option} + " requires an integer in range " + + std::to_string(minimum) + ".." + std::to_string(maximum) + '.'); + } + return static_cast(parsed); +} + +[[nodiscard]] RunMode parse_mode(const std::string_view value) { + if (value == "loopback") { + return RunMode::loopback; + } + if (value == "live") { + return RunMode::live; + } + throw std::invalid_argument("--mode must be loopback or live."); +} + +[[nodiscard]] Options parse_options(const int argc, char** argv) { + Options options; + for (int index = 1; index < argc; ++index) { + const std::string argument = argv[index]; + auto require_value = [&](const std::string_view option) -> std::string { + if (index + 1 >= argc) { + throw std::invalid_argument(std::string{option} + " requires a value."); + } + ++index; + return argv[index]; + }; + + if (argument == "--mode") { + options.mode = parse_mode(require_value("--mode")); + } else if (argument == "--scenario") { + options.scenario = require_value("--scenario"); + } else if (argument == "--frames") { + options.frames = parse_bounded_u64( + "--frames", require_value("--frames"), 1U, 10'000'000U); + } else if (argument == "--drop-every") { + options.drop_every = parse_bounded_u64( + "--drop-every", require_value("--drop-every"), 0U, 10'000'000U); + if (options.drop_every == 1U) { + throw std::invalid_argument( + "--drop-every must be 0 (disabled) or at least 2."); + } + } else if (argument == "--pcap") { + options.pcap_path = require_value("--pcap"); + } else if (argument == "--interface") { + options.interface_selector = require_value("--interface"); + } else if (argument == "--continuous") { + options.continuous = true; + } else if (argument == "--list-interfaces") { + options.list_interfaces = true; + } else if (argument == "--json") { + options.json = true; + } else if (argument == "--capabilities") { + options.capabilities = true; + } else if (argument == "--help" || argument == "-h") { + options.help = true; + } else { + throw std::invalid_argument("Unknown option: " + argument); + } + } + + if (options.continuous && options.frames.has_value()) { + throw std::invalid_argument("--continuous and --frames are mutually exclusive."); + } + if (options.mode == RunMode::loopback && options.continuous) { + throw std::invalid_argument( + "--continuous is reserved for --mode live; loopback remains finite for regression safety."); + } + if (options.mode == RunMode::loopback && !options.interface_selector.empty()) { + throw std::invalid_argument("--interface requires --mode live."); + } + if (options.mode == RunMode::live && options.drop_every != 0U) { + throw std::invalid_argument( + "--drop-every currently applies only to software loopback."); + } + return options; +} + +void print_usage() { + std::cout + << "Usage: ariec61850_sv_injector [options]\n" + << "\nDeterministic IEC 61850 Sampled Values injector.\n" + << "Loopback is the default reference/oracle mode. Windows live mode uses\n" + << "Npcap to transmit raw SV Ethernet frames on a selected NIC.\n\n" + << "Options:\n" + << " --mode MODE loopback | live (default loopback).\n" + << " --scenario NAME normal | protection-fault (default normal).\n" + << " --frames N Finite frame count; defaults to scenario length.\n" + << " --continuous Live mode: transmit until Ctrl+C.\n" + << " --interface NAME Live mode: exact or unique Npcap adapter selector.\n" + << " --list-interfaces List Npcap adapters and exit.\n" + << " --drop-every N Loopback: downstream loss every N TX frames (0=off).\n" + << " --pcap PATH Save observed loopback or transmitted live frames.\n" + << " --json Emit machine-readable final evidence.\n" + << " --capabilities Emit the app/device control capability contract.\n" + << " --help Show this help.\n\n" + << "Live examples:\n" + << " ariec61850_sv_injector --list-interfaces\n" + << " ariec61850_sv_injector --mode live --interface \"Ethernet\" --frames 8000\n" + << " ariec61850_sv_injector --mode live --interface \"Ethernet\" --continuous\n"; +} + +void print_capabilities() { + std::cout + << "{\n" + << " \"schemaVersion\": \"arstack-sv-injector-control-v1\",\n" + << " \"engine\": \"sample-index-fixed-point\",\n" +#ifdef _WIN32 + << " \"modes\": [\"software-loopback\", \"windows-npcap-live\"],\n" +#else + << " \"modes\": [\"software-loopback\"],\n" +#endif + << " \"liveTiming\": \"best-effort-no-catch-up\",\n" + << " \"scenarios\": [\"normal\", \"protection-fault\"],\n" + << " \"commandsPlannedForDeviceTransport\": " + "[\"capabilities\", \"configure\", \"arm\", \"start\", \"stop\", \"status\", \"stats\"],\n" + << " \"profile\": {\"sampleRateHz\": 4000, \"sampleCountWrap\": 4000, " + "\"channels\": 8, \"payload\": \"INT32+quality\"}\n" + << "}\n"; +} + +[[nodiscard]] ScenarioDefinition make_scenario(const std::string_view name) { + auto balanced = sampled_values::make_balanced_4i4v_profile(); + if (name == "normal") { + return {{sampled_values::make_hold_segment(balanced)}, 8'000U, true}; + } + + if (name == "protection-fault") { + auto fault = balanced; + for (std::size_t channel = 0U; channel < 3U; ++channel) { + fault[channel].rms_counts *= 4; + } + for (std::size_t channel = 4U; channel < 7U; ++channel) { + fault[channel].rms_counts /= 4; + } + + std::vector segments; + segments.reserve(3U); + segments.push_back(sampled_values::make_hold_segment(balanced, 2'000U)); + segments.push_back(sampled_values::make_hold_segment(fault, 800U)); + segments.push_back(sampled_values::make_hold_segment(balanced, 2'000U)); + return {std::move(segments), 4'800U, true}; + } + + throw std::invalid_argument( + "Unknown scenario '" + std::string{name} + + "'. Supported: normal, protection-fault."); +} + +[[nodiscard]] sampled_values::SampledValuesFrame make_frame() { + sampled_values::SampledValueAsdu asdu; + asdu.sv_id = "ARSTACK61850_INJECTOR"; + asdu.data_set_reference = "ARSTACK61850/LLN0$PhsMeas1"; + asdu.configuration_revision = 1U; + asdu.sample_synchronization = 0U; + asdu.sample_rate = std::uint16_t{kSampleRateHz}; + asdu.sample_mode = std::uint16_t{1U}; + asdu.sample_payload.resize(kPayloadBytes, 0U); + + return { + kDestination, + kSource, + std::nullopt, + 0x4001U, + 0U, + 0U, + sampled_values::SampledValuesPdu{{std::move(asdu)}}}; +} + +[[nodiscard]] bool frame_identity_matches( + const sampled_values::SampledValuesFrame& frame) noexcept { + if (frame.destination != kDestination || frame.source != kSource || + frame.vlan.has_value() || frame.app_id != 0x4001U || + frame.reserved1 != 0U || frame.reserved2 != 0U || + frame.pdu.asdus.size() != 1U) { + return false; + } + + const auto& asdu = frame.pdu.asdus.front(); + return asdu.sv_id == "ARSTACK61850_INJECTOR" && + asdu.data_set_reference == "ARSTACK61850/LLN0$PhsMeas1" && + asdu.configuration_revision == 1U && + asdu.sample_synchronization == 0U && + asdu.sample_rate == std::optional{std::uint16_t{kSampleRateHz}} && + asdu.sample_mode == std::optional{std::uint16_t{1U}}; +} + +embedded::IoResult loopback_transmit( + void* context, + const std::span bytes) noexcept { + auto* loopback = static_cast(context); + if (loopback == nullptr || bytes.empty()) { + return {embedded::IoStatus::invalid_argument, 0U}; + } + + ++loopback->evidence.tx_frames; + if (loopback->drop_every > 0U && + (loopback->evidence.tx_frames % loopback->drop_every) == 0U) { + ++loopback->evidence.injected_drops; + return {embedded::IoStatus::ok, bytes.size()}; + } + + sampled_values::SampledValuesFrame decoded; + if (!sampled_values::SampledValuesFrameCodec::try_decode(bytes, decoded)) { + ++loopback->evidence.decode_errors; + return {embedded::IoStatus::ok, bytes.size()}; + } + + ++loopback->evidence.rx_frames; + if (!frame_identity_matches(decoded)) { + ++loopback->evidence.identity_errors; + } + + if (decoded.pdu.asdus.size() == 1U) { + const auto& asdu = decoded.pdu.asdus.front(); + if (asdu.sample_count != loopback->expected_sample_count) { + ++loopback->evidence.sample_count_errors; + } + if (asdu.sample_payload.size() != loopback->expected_payload.size() || + !std::equal( + asdu.sample_payload.begin(), + asdu.sample_payload.end(), + loopback->expected_payload.begin(), + loopback->expected_payload.end())) { + ++loopback->evidence.payload_errors; + } + + if (loopback->previous_received_count.has_value()) { + const auto previous = *loopback->previous_received_count; + const auto expected_next = static_cast( + previous + 1U == kSampleCountWrap ? 0U : previous + 1U); + if (asdu.sample_count != expected_next) { + if (asdu.sample_count == loopback->expected_sample_count) { + ++loopback->evidence.gap_events; + } else { + ++loopback->evidence.sample_count_errors; + } + } + } + loopback->previous_received_count = asdu.sample_count; + } + + if (loopback->pcap_writer != nullptr) { + try { + const auto timestamp = std::chrono::system_clock::time_point{ + std::chrono::microseconds{loopback->virtual_timestamp_us}}; + loopback->pcap_writer->write_packet(timestamp, bytes); + } catch (...) { + ++loopback->evidence.pcap_errors; + return {embedded::IoStatus::io_error, 0U}; + } + } + + return {embedded::IoStatus::ok, bytes.size()}; +} + +[[nodiscard]] bool write_payload( + sampled_values::SampledValueAsdu& asdu, + const sampled_values::InjectorSample& sample) noexcept { + if (asdu.sample_payload.size() != kPayloadBytes) { + return false; + } + const auto payload = std::span{ + asdu.sample_payload.data(), asdu.sample_payload.size()}; + for (std::size_t channel = 0U; + channel < sampled_values::injector_channel_count; + ++channel) { + if (!sampled_values::SampledValuesPayloadWriter::write_int32_quality_pair( + payload, + channel, + sample.values[channel], + sample.qualities[channel])) { + return false; + } + } + return true; +} + +#ifdef _WIN32 +[[nodiscard]] std::uint64_t monotonic_us() noexcept { + const auto now = std::chrono::steady_clock::now().time_since_epoch(); + return static_cast( + std::chrono::duration_cast(now).count()); +} + +void wait_until_monotonic_us(const std::uint64_t deadline_us) { + while (gStopRequested == 0) { + const auto now = monotonic_us(); + if (now >= deadline_us) { + return; + } + const auto remaining = deadline_us - now; + if (remaining > 2'000U) { + std::this_thread::sleep_for( + std::chrono::microseconds{static_cast(remaining - 1'000U)}); + } else if (remaining > 100U) { + std::this_thread::yield(); + } + } +} +#endif + +void print_loopback_human( + const Options& options, + const std::uint64_t frames, + const LoopbackEvidence& evidence, + const sampled_values::SampledValuesPublisherStatistics& publisher, + const bool passed) { + std::cout + << "ARStack61850 deterministic SV injector\n\n" + << "Mode : SOFTWARE LOOPBACK\n" + << "Scenario : " << options.scenario << '\n' + << "Engine : sample-index fixed-point\n" + << "Sample rate : " << kSampleRateHz << " Hz\n" + << "Virtual interval : " << kIntervalUs << " us\n" + << "Frames requested : " << frames << "\n\n" + << "TX frames : " << evidence.tx_frames << '\n' + << "RX frames : " << evidence.rx_frames << '\n' + << "Injected drops : " << evidence.injected_drops << '\n' + << "Observed gaps : " << evidence.gap_events << '\n' + << "Decode errors : " << evidence.decode_errors << '\n' + << "Identity errors : " << evidence.identity_errors << '\n' + << "Payload errors : " << evidence.payload_errors << '\n' + << "smpCnt errors : " << evidence.sample_count_errors << '\n' + << "Encode failures : " << publisher.encode_failures << '\n' + << "TX failures : " << publisher.transmit_failures << '\n' + << "Late virtual poll : " << publisher.late_polls << '\n'; + if (!options.pcap_path.empty()) { + std::cout << "PCAP : " << options.pcap_path << '\n'; + } + std::cout << "\nRESULT : " + << (passed ? "LOOPBACK/PASSED" : "LOOPBACK/FAILED") << '\n'; +} + +void print_loopback_json( + const Options& options, + const std::uint64_t frames, + const LoopbackEvidence& evidence, + const sampled_values::SampledValuesPublisherStatistics& publisher, + const bool passed) { + std::cout + << '{' + << "\"schemaVersion\":\"arstack-sv-injector-evidence-v1\"," + << "\"status\":\"" << (passed ? "LOOPBACK/PASSED" : "LOOPBACK/FAILED") << "\"," + << "\"mode\":\"software-loopback\"," + << "\"scenario\":\"" << options.scenario << "\"," + << "\"engine\":\"sample-index-fixed-point\"," + << "\"sampleRateHz\":" << kSampleRateHz << ',' + << "\"intervalUs\":" << kIntervalUs << ',' + << "\"framesRequested\":" << frames << ',' + << "\"txFrames\":" << evidence.tx_frames << ',' + << "\"rxFrames\":" << evidence.rx_frames << ',' + << "\"injectedDrops\":" << evidence.injected_drops << ',' + << "\"gapEvents\":" << evidence.gap_events << ',' + << "\"decodeErrors\":" << evidence.decode_errors << ',' + << "\"identityErrors\":" << evidence.identity_errors << ',' + << "\"payloadErrors\":" << evidence.payload_errors << ',' + << "\"sampleCountErrors\":" << evidence.sample_count_errors << ',' + << "\"encodeFailures\":" << publisher.encode_failures << ',' + << "\"transmitFailures\":" << publisher.transmit_failures << ',' + << "\"lateVirtualPolls\":" << publisher.late_polls << ',' + << "\"pcapErrors\":" << evidence.pcap_errors + << "}\n"; +} + +[[nodiscard]] int run_loopback(const Options& options) { + auto scenario = make_scenario(options.scenario); + const auto frames = options.frames.value_or(scenario.default_frames); + sampled_values::DeterministicSvInjector injector( + scenario.segments, kSampleRateHz, scenario.loop); + if (!injector.valid()) { + throw std::runtime_error("Deterministic injector configuration is invalid."); + } + + std::ofstream pcap_output; + std::optional pcap_writer; + if (!options.pcap_path.empty()) { + pcap_output.open(options.pcap_path, std::ios::binary | std::ios::trunc); + if (!pcap_output) { + throw std::runtime_error("Cannot open PCAP output: " + options.pcap_path); + } + pcap_writer.emplace(pcap_output); + } + + LoopbackContext loopback; + loopback.drop_every = options.drop_every; + loopback.pcap_writer = pcap_writer.has_value() ? &*pcap_writer : nullptr; + embedded::RawEthernetPort port{&loopback, &loopback_transmit}; + + auto frame = make_frame(); + std::array frame_buffer{}; + sampled_values::SampledValuesPublisher publisher( + frame, + frame_buffer, + port, + sampled_values::SampledValuesPublisherConfig{ + kSampleRateHz, + std::uint16_t{kSampleCountWrap}, + 0U, + true}); + if (!publisher.valid()) { + throw std::runtime_error("SampledValuesPublisher configuration is invalid."); + } + + sampled_values::InjectorSample sample{}; + for (std::uint64_t index = 0U; index < frames; ++index) { + if (!injector.step(sample)) { + throw std::runtime_error("Scenario finished before requested frame count."); + } + if (sample.sample_index != index) { + throw std::runtime_error("Injector sample-index timeline diverged."); + } + + auto& asdu = frame.pdu.asdus.front(); + if (!write_payload(asdu, sample)) { + throw std::runtime_error("Could not build INT32+quality SV payload."); + } + + loopback.expected_sample_count = static_cast( + sample.sample_index % static_cast(kSampleCountWrap)); + std::copy( + asdu.sample_payload.begin(), + asdu.sample_payload.end(), + loopback.expected_payload.begin()); + loopback.virtual_timestamp_us = + kVirtualStartUs + sample.sample_index * kIntervalUs; + + const auto result = publisher.poll(loopback.virtual_timestamp_us); + if (!result.sent() || + result.sample_count != loopback.expected_sample_count) { + throw std::runtime_error("Publisher did not emit the expected virtual sample."); + } + } + + if (pcap_output.is_open()) { + pcap_output.flush(); + if (!pcap_output) { + ++loopback.evidence.pcap_errors; + } + } + + const auto expected_rx = frames - loopback.evidence.injected_drops; + const auto expected_visible_gaps = options.drop_every == 0U + ? 0U + : (frames - 1U) / options.drop_every; + const auto& statistics = publisher.statistics(); + const auto passed = + loopback.evidence.tx_frames == frames && + loopback.evidence.rx_frames == expected_rx && + loopback.evidence.decode_errors == 0U && + loopback.evidence.identity_errors == 0U && + loopback.evidence.payload_errors == 0U && + loopback.evidence.sample_count_errors == 0U && + loopback.evidence.gap_events == expected_visible_gaps && + loopback.evidence.pcap_errors == 0U && + statistics.frames_sent == frames && + statistics.encode_failures == 0U && + statistics.transmit_failures == 0U && + statistics.late_polls == 0U; + + if (options.json) { + print_loopback_json(options, frames, loopback.evidence, statistics, passed); + } else { + print_loopback_human(options, frames, loopback.evidence, statistics, passed); + } + return passed ? 0 : 2; +} + +void print_adapters() { + tools::NpcapLivePort live; + std::vector adapters; + if (!live.list_adapters(adapters)) { + throw std::runtime_error(live.error()); + } + + std::cout << "Npcap adapters:\n"; + for (std::size_t index = 0U; index < adapters.size(); ++index) { + const auto& adapter = adapters[index]; + std::cout << '[' << index << "] " + << (adapter.description.empty() ? "(no description)" : adapter.description) + << '\n' + << " " << adapter.name << '\n'; + } + if (adapters.empty()) { + std::cout << "(none)\n"; + } +} + +#ifdef _WIN32 +void print_live_human( + const Options& options, + const tools::NpcapLivePort& live, + const std::uint64_t frames_requested, + const sampled_values::SampledValuesPublisherStatistics& statistics, + const double elapsed_seconds, + const bool passed) { + const auto rate = elapsed_seconds > 0.0 + ? static_cast(statistics.frames_sent) / elapsed_seconds + : 0.0; + + std::cout + << "\nARStack61850 deterministic SV injector\n\n" + << "Mode : LIVE ETHERNET / NPCAP\n" + << "Scenario : " << options.scenario << '\n' + << "Engine : sample-index fixed-point\n" + << "Timing : real-time best-effort, no catch-up bursts\n" + << "Sample rate goal : " << kSampleRateHz << " Hz\n" + << "Interval goal : " << kIntervalUs << " us\n" + << "Adapter : " << live.opened_name() << '\n'; + if (!live.opened_description().empty()) { + std::cout << "Adapter desc : " << live.opened_description() << '\n'; + } + if (options.continuous) { + std::cout << "Run length : continuous until Ctrl+C\n"; + } else { + std::cout << "Frames requested : " << frames_requested << '\n'; + } + std::cout + << "\nTX frames : " << statistics.frames_sent << '\n' + << "Encode failures : " << statistics.encode_failures << '\n' + << "TX failures : " << statistics.transmit_failures << '\n' + << "Late polls : " << statistics.late_polls << '\n' + << "Max lateness : " << statistics.maximum_lateness_us << " us\n" + << "Elapsed : " << std::fixed << std::setprecision(3) + << elapsed_seconds << " s\n" + << "Observed TX rate : " << std::fixed << std::setprecision(1) + << rate << " frames/s\n"; + if (!options.pcap_path.empty()) { + std::cout << "TX PCAP : " << options.pcap_path << '\n'; + } + std::cout + << "External verify : subscriber/Wireshark (eth.type == 0x88ba)\n" + << "\nRESULT : " + << (passed + ? (options.continuous ? "LIVE/STOPPED" : "LIVE/PASSED") + : "LIVE/FAILED") + << '\n'; +} + +void print_live_json( + const Options& options, + const tools::NpcapLivePort& live, + const std::uint64_t frames_requested, + const sampled_values::SampledValuesPublisherStatistics& statistics, + const double elapsed_seconds, + const bool passed) { + const auto rate = elapsed_seconds > 0.0 + ? static_cast(statistics.frames_sent) / elapsed_seconds + : 0.0; + std::cout + << '{' + << "\"schemaVersion\":\"arstack-sv-injector-live-evidence-v1\"," + << "\"status\":\"" + << (passed + ? (options.continuous ? "LIVE/STOPPED" : "LIVE/PASSED") + : "LIVE/FAILED") + << "\"," + << "\"mode\":\"windows-npcap-live\"," + << "\"scenario\":\"" << options.scenario << "\"," + << "\"engine\":\"sample-index-fixed-point\"," + << "\"timing\":\"best-effort-no-catch-up\"," + << "\"sampleRateHz\":" << kSampleRateHz << ',' + << "\"intervalUs\":" << kIntervalUs << ',' + << "\"continuous\":" << (options.continuous ? "true" : "false") << ',' + << "\"framesRequested\":" << frames_requested << ',' + << "\"txFrames\":" << statistics.frames_sent << ',' + << "\"encodeFailures\":" << statistics.encode_failures << ',' + << "\"transmitFailures\":" << statistics.transmit_failures << ',' + << "\"latePolls\":" << statistics.late_polls << ',' + << "\"maximumLatenessUs\":" << statistics.maximum_lateness_us << ',' + << "\"elapsedSeconds\":" << std::fixed << std::setprecision(6) + << elapsed_seconds << ',' + << "\"observedTxRate\":" << std::fixed << std::setprecision(3) << rate << ',' + << "\"adapter\":\"" << live.opened_name() << "\"" + << "}\n"; +} +#endif + +[[nodiscard]] int run_live(const Options& options) { +#ifndef _WIN32 + (void)options; + throw std::runtime_error("Live Npcap mode is available only on Windows."); +#else + if (options.interface_selector.empty()) { + throw std::invalid_argument( + "--mode live requires --interface NAME. Use --list-interfaces first."); + } + + tools::NpcapLivePort live; + if (!live.open(options.interface_selector)) { + throw std::runtime_error(live.error()); + } + + auto scenario = make_scenario(options.scenario); + const auto frames_requested = options.continuous + ? 0U + : options.frames.value_or(scenario.default_frames); + sampled_values::DeterministicSvInjector injector( + scenario.segments, kSampleRateHz, scenario.loop); + if (!injector.valid()) { + throw std::runtime_error("Deterministic injector configuration is invalid."); + } + + std::ofstream pcap_output; + std::optional pcap_writer; + if (!options.pcap_path.empty()) { + pcap_output.open(options.pcap_path, std::ios::binary | std::ios::trunc); + if (!pcap_output) { + throw std::runtime_error("Cannot open PCAP output: " + options.pcap_path); + } + pcap_writer.emplace(pcap_output); + } + + auto frame = make_frame(); + std::array frame_buffer{}; + sampled_values::SampledValuesPublisher publisher( + frame, + frame_buffer, + live.raw_port(), + sampled_values::SampledValuesPublisherConfig{ + kSampleRateHz, + std::uint16_t{kSampleCountWrap}, + 0U, + true}); + if (!publisher.valid()) { + throw std::runtime_error("SampledValuesPublisher configuration is invalid."); + } + + if (!options.json) { + std::cout + << "Starting live SV transmission on:\n " << live.opened_name() << '\n'; + if (!live.opened_description().empty()) { + std::cout << " " << live.opened_description() << '\n'; + } + std::cout + << "Profile: 50 Hz, 4000 samples/s, APPID 0x4001, dst 01:0C:CD:04:00:01\n"; + if (options.continuous) { + std::cout << "Press Ctrl+C to stop cleanly.\n"; + } + } + + gStopRequested = 0; + const auto previous_handler = std::signal(SIGINT, handle_interrupt); + const auto started_at = std::chrono::steady_clock::now(); + + sampled_values::InjectorSample sample{}; + std::uint64_t index = 0U; + while (gStopRequested == 0 && + (options.continuous || index < frames_requested)) { + if (publisher.started()) { + wait_until_monotonic_us(publisher.next_due_us()); + if (gStopRequested != 0) { + break; + } + } + + if (!injector.step(sample)) { + std::signal(SIGINT, previous_handler); + throw std::runtime_error("Scenario finished before requested frame count."); + } + if (sample.sample_index != index) { + std::signal(SIGINT, previous_handler); + throw std::runtime_error("Injector sample-index timeline diverged."); + } + + auto& asdu = frame.pdu.asdus.front(); + if (!write_payload(asdu, sample)) { + std::signal(SIGINT, previous_handler); + throw std::runtime_error("Could not build INT32+quality SV payload."); + } + + sampled_values::SampledValuesPublishResult result{}; + do { + const auto now_us = monotonic_us(); + result = publisher.poll(now_us); + if (result.status == sampled_values::SampledValuesPublishStatus::not_due) { + wait_until_monotonic_us(result.next_due_us); + } + } while (result.status == sampled_values::SampledValuesPublishStatus::not_due && + gStopRequested == 0); + + if (gStopRequested != 0) { + break; + } + if (!result.sent()) { + std::signal(SIGINT, previous_handler); + if (result.status == sampled_values::SampledValuesPublishStatus::transmit_failed) { + throw std::runtime_error( + "Npcap transmit failed: " + + (live.error().empty() ? std::string{"unknown error"} : live.error())); + } + throw std::runtime_error("Live publisher failed to encode/transmit a frame."); + } + + const auto expected_sample_count = static_cast( + sample.sample_index % static_cast(kSampleCountWrap)); + if (result.sample_count != expected_sample_count) { + std::signal(SIGINT, previous_handler); + throw std::runtime_error("Live publisher smpCnt diverged from logical sample index."); + } + + if (pcap_writer.has_value()) { + try { + pcap_writer->write_packet( + std::chrono::system_clock::now(), + std::span{frame_buffer.data(), result.frame_bytes}); + } catch (...) { + std::signal(SIGINT, previous_handler); + throw std::runtime_error("Could not write live TX PCAP evidence."); + } + } + + ++index; + } + + const auto stopped_at = std::chrono::steady_clock::now(); + std::signal(SIGINT, previous_handler); + + if (pcap_output.is_open()) { + pcap_output.flush(); + if (!pcap_output) { + throw std::runtime_error("Could not flush live TX PCAP evidence."); + } + } + + const auto elapsed_seconds = + std::chrono::duration(stopped_at - started_at).count(); + const auto& statistics = publisher.statistics(); + const auto finite_complete = options.continuous || statistics.frames_sent == frames_requested; + const auto passed = finite_complete && + statistics.encode_failures == 0U && + statistics.transmit_failures == 0U; + + if (options.json) { + print_live_json( + options, live, frames_requested, statistics, elapsed_seconds, passed); + } else { + print_live_human( + options, live, frames_requested, statistics, elapsed_seconds, passed); + } + return passed ? 0 : 2; +#endif +} + +} // namespace + +int main(const int argc, char** argv) { + try { + const auto options = parse_options(argc, argv); + if (options.help) { + print_usage(); + return 0; + } + if (options.capabilities) { + print_capabilities(); + return 0; + } + if (options.list_interfaces) { + print_adapters(); + return 0; + } + return options.mode == RunMode::live + ? run_live(options) + : run_loopback(options); + } catch (const std::exception& error) { + std::cerr << "ariec61850_sv_injector: " << error.what() << '\n'; + return 1; + } +} diff --git a/tools/sv_replay_live.cpp b/tools/sv_replay_live.cpp new file mode 100644 index 000000000..83ee4f2d8 --- /dev/null +++ b/tools/sv_replay_live.cpp @@ -0,0 +1,388 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/sampled_values/frame.hpp" +#include "ariec61850/sampled_values/publisher.hpp" +#include "ariec61850/sampled_values/replay_bundle.hpp" +#include "npcap_live.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::uint64_t kMaximumBundleBytes = 2ULL * 1024ULL * 1024ULL * 1024ULL; + +#ifdef _WIN32 +constexpr std::uint16_t kSampleCountWrap = 4'000U; +constexpr std::size_t kFrameBufferBytes = 1'536U; +const ethernet::MacAddress kDestination{ + std::array{0x01U, 0x0CU, 0xCDU, 0x04U, 0x00U, 0x01U}}; +const ethernet::MacAddress kSource{ + std::array{0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x02U}}; + +volatile std::sig_atomic_t gStopRequested = 0; + +void handle_interrupt(const int signal) noexcept { + if (signal == SIGINT) { + gStopRequested = 1; + } +} +#endif + +struct Options final { + std::filesystem::path bundle_path; + std::string interface_selector; + bool continuous{}; + bool validate{}; + bool list_interfaces{}; + bool help{}; +}; + +struct LoadedBundle final { + sampled_values::ReplayBundleHeader header{}; + std::vector bytes; + + [[nodiscard]] std::span payload( + const std::uint64_t frame_index) const { + if (frame_index >= header.frame_count) { + throw std::out_of_range("Replay frame index exceeds bundle."); + } + const auto offset = static_cast( + sampled_values::replay_bundle_header_bytes) + + frame_index * static_cast(header.payload_bytes); + const auto begin = static_cast(offset); + return std::span{ + bytes.data() + begin, + static_cast(header.payload_bytes)}; + } +}; + +[[nodiscard]] Options parse_options(const int argc, char** argv) { + Options options; + for (int index = 1; index < argc; ++index) { + const std::string argument = argv[index]; + auto require_value = [&](const std::string_view option) -> std::string { + if (index + 1 >= argc) { + throw std::invalid_argument(std::string{option} + " requires a value."); + } + ++index; + return argv[index]; + }; + + if (argument == "--bundle") { + options.bundle_path = require_value("--bundle"); + } else if (argument == "--interface") { + options.interface_selector = require_value("--interface"); + } else if (argument == "--continuous") { + options.continuous = true; + } else if (argument == "--validate") { + options.validate = true; + } else if (argument == "--list-interfaces") { + options.list_interfaces = true; + } else if (argument == "--help" || argument == "-h") { + options.help = true; + } else { + throw std::invalid_argument("Unknown option: " + argument); + } + } + + if (!options.help && !options.list_interfaces && options.bundle_path.empty()) { + throw std::invalid_argument("--bundle PATH is required."); + } + if (!options.help && !options.list_interfaces && !options.validate && + options.interface_selector.empty()) { + throw std::invalid_argument( + "Live replay requires --interface NAME. Use --validate for offline bundle validation."); + } + return options; +} + +void print_usage() { + std::cout + << "Usage: ariec61850_sv_replay_live --bundle FILE [options]\n\n" + << "Replay a normalized recorded-waveform bundle as IEC 61850 Sampled Values.\n\n" + << "Options:\n" + << " --bundle PATH Replay bundle produced by ariec61850_sv_replay_prepare.\n" + << " --interface NAME Exact or unique Npcap adapter selector.\n" + << " --continuous Repeat the bundle until Ctrl+C.\n" + << " --validate Validate bundle offline and exit.\n" + << " --list-interfaces List Npcap adapters and exit.\n" + << " --help Show this help.\n\n" + << "Examples:\n" + << " ariec61850_sv_replay_live --bundle fault.arsvr --validate\n" + << " ariec61850_sv_replay_live --bundle fault.arsvr --interface \"Ethernet\"\n" + << " ariec61850_sv_replay_live --bundle fault.arsvr --interface \"Ethernet\" --continuous\n"; +} + +[[nodiscard]] LoadedBundle load_bundle(const std::filesystem::path& path) { + std::error_code error; + const auto file_size = std::filesystem::file_size(path, error); + if (error) { + throw std::runtime_error("Could not read replay bundle size: " + error.message()); + } + if (file_size < sampled_values::replay_bundle_header_bytes || + file_size > kMaximumBundleBytes || + file_size > static_cast(std::numeric_limits::max())) { + throw std::runtime_error("Replay bundle size is outside supported host limits."); + } + + LoadedBundle bundle; + bundle.bytes.resize(static_cast(file_size)); + std::ifstream input(path, std::ios::binary); + if (!input) { + throw std::runtime_error("Could not open replay bundle."); + } + input.read( + reinterpret_cast(bundle.bytes.data()), + static_cast(bundle.bytes.size())); + if (!input || input.gcount() != static_cast(bundle.bytes.size())) { + throw std::runtime_error("Could not read complete replay bundle."); + } + + if (!sampled_values::decode_replay_bundle_header( + std::span{bundle.bytes.data(), bundle.bytes.size()}, + bundle.header)) { + throw std::runtime_error("Replay bundle header is invalid."); + } + if (bundle.header.sample_rate_hz != 4'000U) { + throw std::runtime_error("Replay live v1 requires a 4000 Hz bundle."); + } + if (bundle.header.frame_count == 0U) { + throw std::runtime_error("Replay bundle contains no frames."); + } + const auto expected = sampled_values::replay_bundle_expected_bytes(bundle.header); + if (expected == 0U || expected != static_cast(bundle.bytes.size())) { + throw std::runtime_error("Replay bundle byte count does not match its header."); + } + return bundle; +} + +#ifdef _WIN32 +[[nodiscard]] sampled_values::SampledValuesFrame make_frame() { + sampled_values::SampledValueAsdu asdu; + asdu.sv_id = "ARSTACK61850_INJECTOR"; + asdu.data_set_reference = "ARSTACK61850/LLN0$PhsMeas1"; + asdu.configuration_revision = 1U; + asdu.sample_synchronization = 0U; + asdu.sample_rate = std::uint16_t{4'000U}; + asdu.sample_mode = std::uint16_t{1U}; + asdu.sample_payload.resize(sampled_values::replay_payload_bytes, 0U); + + return { + kDestination, + kSource, + std::nullopt, + 0x4001U, + 0U, + 0U, + sampled_values::SampledValuesPdu{{std::move(asdu)}}}; +} +#endif + +void print_bundle_summary( + const std::filesystem::path& path, + const LoadedBundle& bundle) { + const auto duration = static_cast(bundle.header.frame_count) / + static_cast(bundle.header.sample_rate_hz); + std::cout + << "Replay bundle : " << path.string() << '\n' + << "Sample rate : " << bundle.header.sample_rate_hz << " Hz\n" + << "Frames : " << bundle.header.frame_count << '\n' + << "Payload/frame : " << bundle.header.payload_bytes << " bytes\n" + << "Duration : " << std::fixed << std::setprecision(6) + << duration << " s\n"; +} + +void list_adapters() { + tools::NpcapLivePort live; + std::vector adapters; + if (!live.list_adapters(adapters)) { + throw std::runtime_error(live.error()); + } + std::cout << "Npcap adapters:\n"; + for (std::size_t index = 0U; index < adapters.size(); ++index) { + std::cout << '[' << index << "] " + << (adapters[index].description.empty() + ? "(no description)" + : adapters[index].description) + << '\n' + << " " << adapters[index].name << '\n'; + } +} + +#ifdef _WIN32 +[[nodiscard]] std::uint64_t monotonic_us() noexcept { + const auto now = std::chrono::steady_clock::now().time_since_epoch(); + return static_cast( + std::chrono::duration_cast(now).count()); +} + +void wait_until(const std::uint64_t deadline_us) { + while (gStopRequested == 0) { + const auto now = monotonic_us(); + if (now >= deadline_us) { + return; + } + const auto remaining = deadline_us - now; + if (remaining > 2'000U) { + std::this_thread::sleep_for( + std::chrono::microseconds{static_cast(remaining - 1'000U)}); + } else if (remaining > 100U) { + std::this_thread::yield(); + } + } +} +#endif + +[[nodiscard]] int run_live(const Options& options, const LoadedBundle& bundle) { +#ifndef _WIN32 + (void)options; + (void)bundle; + throw std::runtime_error("Live Npcap replay is available only on Windows."); +#else + tools::NpcapLivePort live; + if (!live.open(options.interface_selector)) { + throw std::runtime_error(live.error()); + } + + auto frame = make_frame(); + std::array frame_buffer{}; + auto raw_port = live.raw_port(); + sampled_values::SampledValuesPublisher publisher( + frame, + frame_buffer, + raw_port, + sampled_values::SampledValuesPublisherConfig{ + bundle.header.sample_rate_hz, + std::uint16_t{kSampleCountWrap}, + 0U, + true}); + if (!publisher.valid()) { + throw std::runtime_error("SV replay publisher configuration is invalid."); + } + + gStopRequested = 0; + const auto previous_handler = std::signal(SIGINT, &handle_interrupt); + const auto started_at = std::chrono::steady_clock::now(); + std::uint64_t logical_index{}; + + while (gStopRequested == 0 && + (options.continuous || logical_index < bundle.header.frame_count)) { + if (publisher.started()) { + wait_until(publisher.next_due_us()); + if (gStopRequested != 0) { + break; + } + } + + const auto replay_index = logical_index % bundle.header.frame_count; + const auto payload = bundle.payload(replay_index); + auto& destination = frame.pdu.asdus.front().sample_payload; + std::copy(payload.begin(), payload.end(), destination.begin()); + + sampled_values::SampledValuesPublishResult result{}; + do { + result = publisher.poll(monotonic_us()); + if (result.status == sampled_values::SampledValuesPublishStatus::not_due) { + wait_until(result.next_due_us); + } + } while (result.status == sampled_values::SampledValuesPublishStatus::not_due && + gStopRequested == 0); + + if (gStopRequested != 0) { + break; + } + if (!result.sent()) { + std::signal(SIGINT, previous_handler); + throw std::runtime_error( + result.status == sampled_values::SampledValuesPublishStatus::transmit_failed + ? "Npcap replay transmit failed: " + live.error() + : "Replay publisher failed to encode/transmit a frame."); + } + + const auto expected_count = static_cast( + logical_index % static_cast(kSampleCountWrap)); + if (result.sample_count != expected_count) { + std::signal(SIGINT, previous_handler); + throw std::runtime_error("Replay smpCnt diverged from logical sample index."); + } + ++logical_index; + } + + const auto stopped_at = std::chrono::steady_clock::now(); + std::signal(SIGINT, previous_handler); + const auto elapsed = std::chrono::duration(stopped_at - started_at).count(); + const auto& stats = publisher.statistics(); + const auto rate = elapsed > 0.0 + ? static_cast(stats.frames_sent) / elapsed + : 0.0; + const auto passed = stats.encode_failures == 0U && + stats.transmit_failures == 0U && + (options.continuous || stats.frames_sent == bundle.header.frame_count); + + std::cout + << "\nARStack61850 recorded-waveform live replay\n\n" + << "Adapter : " << live.opened_name() << '\n' + << "Frames sent : " << stats.frames_sent << '\n' + << "Encode failures : " << stats.encode_failures << '\n' + << "TX failures : " << stats.transmit_failures << '\n' + << "Late polls : " << stats.late_polls << '\n' + << "Max lateness : " << stats.maximum_lateness_us << " us\n" + << "Observed TX rate : " << std::fixed << std::setprecision(1) + << rate << " frames/s\n" + << "RESULT : " + << (passed + ? (options.continuous ? "REPLAY-LIVE/STOPPED" : "REPLAY-LIVE/PASSED") + : "REPLAY-LIVE/FAILED") + << '\n'; + return passed ? 0 : 2; +#endif +} + +} // namespace + +int main(const int argc, char** argv) { + try { + const auto options = parse_options(argc, argv); + if (options.help) { + print_usage(); + return 0; + } + if (options.list_interfaces) { + list_adapters(); + return 0; + } + + const auto bundle = load_bundle(options.bundle_path); + print_bundle_summary(options.bundle_path, bundle); + if (options.validate) { + std::cout << "RESULT : REPLAY-BUNDLE/VALID\n"; + return 0; + } + return run_live(options, bundle); + } catch (const std::exception& error) { + std::cerr << "ariec61850_sv_replay_live: " << error.what() << '\n'; + return 1; + } +} diff --git a/tools/sv_replay_prepare.cpp b/tools/sv_replay_prepare.cpp new file mode 100644 index 000000000..00fcdb79e --- /dev/null +++ b/tools/sv_replay_prepare.cpp @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/comtrade/reader.hpp" +#include "ariec61850/sampled_values/payload_writer.hpp" +#include "ariec61850/sampled_values/replay_bundle.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::uint32_t kTargetSampleRateHz = 4'000U; +constexpr std::array + kChannelNames{"Ia", "Ib", "Ic", "In", "Va", "Vb", "Vc", "Vn"}; + +[[nodiscard]] double unit_to_base_factor(const std::string_view unit) { + if (unit == "A" || unit == "V") { + return 1.0; + } + if (unit == "kA" || unit == "kV") { + return 1'000.0; + } + if (unit == "mA" || unit == "mV") { + return 0.001; + } + throw std::runtime_error( + "Mapped analog channel uses unsupported engineering unit '" + + std::string{unit} + "'. Expected A, kA, mA, V, kV, or mV."); +} + +[[nodiscard]] std::int32_t to_replay_count( + const double engineering_value, + const std::string_view unit) { + if (!std::isfinite(engineering_value)) { + throw std::runtime_error("Record contains a non-finite analog value."); + } + const auto base_value = engineering_value * unit_to_base_factor(unit); + if (!std::isfinite(base_value) || + base_value < static_cast(std::numeric_limits::min()) || + base_value > static_cast(std::numeric_limits::max())) { + throw std::runtime_error("Mapped analog value exceeds INT32 replay range."); + } + return static_cast(std::llround(base_value)); +} + +[[nodiscard]] sampled_values::ReplayPayloadFrame make_frame( + const comtrade::Dataset& dataset, + const comtrade::Sample& sample) { + sampled_values::ReplayPayloadFrame frame{}; + const auto bytes = std::span{frame.data(), frame.size()}; + + for (std::size_t output = 0U; output < kChannelNames.size(); ++output) { + std::int32_t value{}; + const auto found = dataset.default_channel_map.find(kChannelNames[output]); + if (found != dataset.default_channel_map.end()) { + const auto analog_index = found->second; + if (analog_index >= sample.analog_values.size() || + analog_index >= dataset.configuration.analog_channels.size()) { + throw std::runtime_error("Default channel map points outside analog data."); + } + value = to_replay_count( + sample.analog_values[analog_index], + dataset.configuration.analog_channels[analog_index].unit); + } + + if (!sampled_values::SampledValuesPayloadWriter::write_int32_quality_pair( + bytes, output, value, 0U)) { + throw std::runtime_error("Could not encode normalized replay payload."); + } + } + return frame; +} + +void require_supported_sample_rate(const comtrade::Dataset& dataset) { + const auto rate = dataset.nominal_sample_rate_hz(); + if (!std::isfinite(rate) || + std::abs(rate - static_cast(kTargetSampleRateHz)) > 1.0e-6) { + throw std::runtime_error( + "Record sample rate must be exactly 4000 Hz for replay bundle v1. " + "Resampling is intentionally not implicit."); + } +} + +void write_bundle( + const comtrade::Dataset& dataset, + const std::filesystem::path& output_path) { + require_supported_sample_rate(dataset); + if (dataset.samples.empty()) { + throw std::runtime_error("Record contains no samples."); + } + + sampled_values::ReplayBundleHeader header{}; + header.sample_rate_hz = kTargetSampleRateHz; + header.frame_count = static_cast(dataset.samples.size()); + + std::array encoded_header{}; + if (!sampled_values::encode_replay_bundle_header(header, encoded_header)) { + throw std::runtime_error("Could not encode replay bundle header."); + } + + std::ofstream output(output_path, std::ios::binary | std::ios::trunc); + if (!output) { + throw std::runtime_error("Could not open replay bundle output."); + } + output.write( + reinterpret_cast(encoded_header.data()), + static_cast(encoded_header.size())); + + for (const auto& sample : dataset.samples) { + const auto frame = make_frame(dataset, sample); + output.write( + reinterpret_cast(frame.data()), + static_cast(frame.size())); + if (!output) { + throw std::runtime_error("Failed while writing replay bundle payload."); + } + } + output.flush(); + if (!output) { + throw std::runtime_error("Failed to flush replay bundle output."); + } +} + +void print_mapping(const comtrade::Dataset& dataset) { + std::cout << "Channel map :"; + for (const auto name : kChannelNames) { + const auto found = dataset.default_channel_map.find(name); + if (found == dataset.default_channel_map.end()) { + std::cout << ' ' << name << "=0"; + } else { + std::cout << ' ' << name << "=analog[" << found->second << ']'; + } + } + std::cout << '\n'; +} + +} // namespace + +int main(const int argc, char** argv) { + using namespace ar::iec61850; + + if (argc != 3) { + std::cerr + << "Usage: ariec61850_sv_replay_prepare \n" + << "Creates a normalized 4000-sample/s 8-channel SV replay bundle.\n"; + return 2; + } + + try { + const auto dataset = comtrade::Reader{}.load(argv[1]); + write_bundle(dataset, argv[2]); + const sampled_values::ReplayBundleHeader header{ + 1U, + kTargetSampleRateHz, + static_cast(sampled_values::replay_payload_bytes), + static_cast(sampled_values::injector_channel_count), + static_cast(dataset.samples.size())}; + + std::cout + << "ARStack61850 recorded-waveform replay preparation\n\n" + << "Input : " << argv[1] << '\n' + << "Output : " << argv[2] << '\n' + << "Source samples : " << dataset.samples.size() << '\n' + << "Sample rate : " << kTargetSampleRateHz << " Hz\n" + << "Payload/frame : " << sampled_values::replay_payload_bytes << " bytes\n" + << "Bundle bytes : " + << sampled_values::replay_bundle_expected_bytes(header) << '\n'; + print_mapping(dataset); + std::cout << "RESULT : REPLAY-BUNDLE/PREPARED\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "ariec61850_sv_replay_prepare: " << error.what() << '\n'; + return 1; + } +} diff --git a/tools/verify_sv_pcap.py b/tools/verify_sv_pcap.py new file mode 100755 index 000000000..71fc4ae9b --- /dev/null +++ b/tools/verify_sv_pcap.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import hashlib +import json +import struct +import sys +from pathlib import Path + +DESTINATION_MAC = bytes.fromhex("010ccd040001") +SOURCE_MAC = bytes.fromhex("020000000002") +ETHERTYPE_SV = 0x88BA +APPID = 0x4001 +SV_ID = b"ARSTACK61850_P4_TRIAL" +DATASET = b"ARSTACK61850/LLN0$PhsMeas1" +SAMPLE_RATE_HZ = 4_000 +SAMPLE_INTERVAL_US = 250 +SAMPLE_COUNT_WRAP = 4_000 +CHANNEL_COUNT = 8 +CAPTURE_START_US = 1_000_000 + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ValueError(message) + + +def ber_length(length: int) -> bytes: + require(length >= 0, "negative BER length") + if length < 0x80: + return bytes((length,)) + width = max(1, (length.bit_length() + 7) // 8) + require(width <= 4, "BER length exceeds verifier profile") + return bytes((0x80 | width,)) + length.to_bytes(width, "big") + + +def tlv(tag: int, value: bytes) -> bytes: + return bytes((tag,)) + ber_length(len(value)) + value + + +def unsigned_value(value: int) -> bytes: + require(value >= 0, "negative unsigned value") + width = max(1, (value.bit_length() + 7) // 8) + return value.to_bytes(width, "big") + + +def synthetic_payload(sample_count: int) -> bytes: + payload = bytearray() + for channel in range(CHANNEL_COUNT): + value = sample_count * 1_000 + channel * 100_000 + payload.extend(struct.pack(">iI", value, 0)) + return bytes(payload) + + +def reference_frame(sample_count: int) -> bytes: + asdu_content = b"".join( + ( + tlv(0x80, SV_ID), + tlv(0x81, DATASET), + tlv(0x82, unsigned_value(sample_count)), + tlv(0x83, unsigned_value(1)), + tlv(0x85, unsigned_value(0)), + tlv(0x86, unsigned_value(SAMPLE_RATE_HZ)), + tlv(0x87, synthetic_payload(sample_count)), + tlv(0x88, unsigned_value(1)), + ) + ) + asdu = tlv(0x30, asdu_content) + apdu = tlv(0x60, tlv(0x80, unsigned_value(1)) + tlv(0xA2, asdu)) + declared_length = 8 + len(apdu) + process_bus = struct.pack(">HHHH", APPID, declared_length, 0, 0) + apdu + return ( + DESTINATION_MAC + + SOURCE_MAC + + struct.pack(">H", ETHERTYPE_SV) + + process_bus + ) + + +def reference_pcap(frame_count: int) -> bytes: + output = bytearray( + struct.pack( + " tuple[int, bytes, int]: + require(offset + 2 <= len(data), "truncated BER TLV header") + tag = data[offset] + first_length = data[offset + 1] + cursor = offset + 2 + if first_length & 0x80: + width = first_length & 0x7F + require(width > 0, "indefinite BER length is not accepted") + require(width <= 4, "BER length width exceeds verifier profile") + require(cursor + width <= len(data), "truncated BER long-form length") + length = int.from_bytes(data[cursor : cursor + width], "big") + cursor += width + else: + length = first_length + end = cursor + length + require(end <= len(data), "BER value extends past packet boundary") + return tag, data[cursor:end], end + + +def parse_asdu(asdu: bytes, expected_count: int) -> None: + fields: dict[int, bytes] = {} + offset = 0 + while offset < len(asdu): + tag, value, offset = read_tlv(asdu, offset) + require(tag not in fields, f"duplicate ASDU field tag 0x{tag:02x}") + fields[tag] = value + + required = {0x80, 0x81, 0x82, 0x83, 0x85, 0x86, 0x87, 0x88} + require(required.issubset(fields), "missing required ASDU field") + require(0x84 not in fields, "refrTm must be absent in first-trial profile") + require(fields[0x80] == SV_ID, "unexpected svID") + require(fields[0x81] == DATASET, "unexpected DataSet reference") + require(int.from_bytes(fields[0x82], "big") == expected_count, "smpCnt mismatch") + require(int.from_bytes(fields[0x83], "big") == 1, "confRev mismatch") + require(int.from_bytes(fields[0x85], "big") == 0, "smpSynch mismatch") + require(int.from_bytes(fields[0x86], "big") == SAMPLE_RATE_HZ, "smpRate mismatch") + require(int.from_bytes(fields[0x88], "big") == 1, "smpMod mismatch") + require(fields[0x87] == synthetic_payload(expected_count), "seqData payload mismatch") + + +def parse_frame(frame: bytes, expected_count: int) -> None: + require(len(frame) >= 22, "Ethernet frame too short") + require(frame[:6] == DESTINATION_MAC, "destination MAC mismatch") + require(frame[6:12] == SOURCE_MAC, "source MAC mismatch") + ether_type = int.from_bytes(frame[12:14], "big") + require(ether_type != 0x8100, "VLAN tag present in untagged first-trial profile") + require(ether_type == ETHERTYPE_SV, "EtherType is not IEC 61850 Sampled Values") + + appid, declared_length, reserved1, reserved2 = struct.unpack(">HHHH", frame[14:22]) + require(appid == APPID, "APPID mismatch") + require(declared_length == len(frame) - 14, "process-bus declared length mismatch") + require(reserved1 == 0 and reserved2 == 0, "reserved process-bus fields must be zero") + + tag, apdu_value, end = read_tlv(frame, 22) + require(tag == 0x60, "SV APDU application tag mismatch") + require(end == len(frame), "trailing bytes after SV APDU") + + tag, no_asdu, cursor = read_tlv(apdu_value, 0) + require(tag == 0x80, "missing noASDU field") + require(int.from_bytes(no_asdu, "big") == 1, "first-trial profile must contain one ASDU") + tag, sequence, cursor = read_tlv(apdu_value, cursor) + require(tag == 0xA2, "missing seqASDU field") + require(cursor == len(apdu_value), "unexpected fields after seqASDU") + + tag, asdu, asdu_end = read_tlv(sequence, 0) + require(tag == 0x30, "ASDU must use universal SEQUENCE tag") + require(asdu_end == len(sequence), "first-trial profile must contain exactly one ASDU") + parse_asdu(asdu, expected_count) + + +def parse_pcap(raw: bytes) -> list[tuple[int, bytes]]: + require(len(raw) >= 24, "PCAP global header missing") + magic = raw[:4] + if magic == b"\xd4\xc3\xb2\xa1": + endian = "<" + nanoseconds = False + elif magic == b"\xa1\xb2\xc3\xd4": + endian = ">" + nanoseconds = False + elif magic == b"\x4d\x3c\xb2\xa1": + endian = "<" + nanoseconds = True + elif magic == b"\xa1\xb2\x3c\x4d": + endian = ">" + nanoseconds = True + else: + raise ValueError("unsupported PCAP magic") + + _, major, minor, _, _, snaplen, network = struct.unpack(endian + "IHHIIII", raw[:24]) + require((major, minor) == (2, 4), "unexpected PCAP version") + require(snaplen >= 1_536, "PCAP snaplen is too small") + require(network == 1, "PCAP link type is not Ethernet") + + records: list[tuple[int, bytes]] = [] + offset = 24 + while offset < len(raw): + require(offset + 16 <= len(raw), "truncated PCAP record header") + seconds, fraction, included, original = struct.unpack( + endian + "IIII", raw[offset : offset + 16] + ) + offset += 16 + require(included == original, "truncated PCAP packet is not accepted") + require(offset + included <= len(raw), "truncated PCAP packet data") + frame = raw[offset : offset + included] + offset += included + if nanoseconds: + require(fraction % 1_000 == 0, "nanosecond timestamp is not microsecond-aligned") + fraction //= 1_000 + timestamp_us = seconds * 1_000_000 + fraction + records.append((timestamp_us, frame)) + return records + + +def first_difference(left: bytes, right: bytes) -> int | None: + limit = min(len(left), len(right)) + for index in range(limit): + if left[index] != right[index]: + return index + return None if len(left) == len(right) else limit + + +def verify(path: Path, expected_frames: int, report_path: Path | None) -> dict[str, object]: + raw = path.read_bytes() + expected = reference_pcap(expected_frames) + if raw != expected: + mismatch = first_difference(raw, expected) + raise ValueError( + "PCAP differs from independent exact-byte reference" + + ("" if mismatch is None else f" at byte offset {mismatch}") + ) + + records = parse_pcap(raw) + require(len(records) == expected_frames, "unexpected PCAP frame count") + + previous_timestamp: int | None = None + first_frame_hash = "" + last_frame_hash = "" + for index, (timestamp_us, frame) in enumerate(records): + expected_timestamp = CAPTURE_START_US + index * SAMPLE_INTERVAL_US + require(timestamp_us == expected_timestamp, "PCAP timestamp cadence mismatch") + if previous_timestamp is not None: + require(timestamp_us - previous_timestamp == SAMPLE_INTERVAL_US, "packet interval mismatch") + previous_timestamp = timestamp_us + + expected_count = index % SAMPLE_COUNT_WRAP + parse_frame(frame, expected_count) + frame_hash = hashlib.sha256(frame).hexdigest() + if index == 0: + first_frame_hash = frame_hash + last_frame_hash = frame_hash + + report: dict[str, object] = { + "status": "SIMULATED/PASSED", + "verifier": "independent-python-exact-byte-reference", + "pcap": str(path), + "pcap_sha256": hashlib.sha256(raw).hexdigest(), + "frames": len(records), + "sample_rate_hz": SAMPLE_RATE_HZ, + "interval_us": SAMPLE_INTERVAL_US, + "sample_count_wrap": SAMPLE_COUNT_WRAP, + "ether_type": f"0x{ETHERTYPE_SV:04x}", + "appid": f"0x{APPID:04x}", + "destination_mac": DESTINATION_MAC.hex(":"), + "source_mac": SOURCE_MAC.hex(":"), + "sv_id": SV_ID.decode("ascii"), + "dataset": DATASET.decode("ascii"), + "payload_bytes": CHANNEL_COUNT * 8, + "first_frame_sha256": first_frame_hash, + "last_frame_sha256": last_frame_hash, + } + + if report_path is not None: + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return report + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Independently verify the deterministic arstack61850 SV host-simulation PCAP." + ) + parser.add_argument("pcap", type=Path) + parser.add_argument("--expected-frames", type=int, default=8_000) + parser.add_argument("--report", type=Path) + args = parser.parse_args() + + try: + require(args.expected_frames > 0, "expected frame count must be positive") + report = verify(args.pcap, args.expected_frames, args.report) + except (OSError, ValueError) as exc: + print(f"SV PCAP QA FAILED: {exc}", file=sys.stderr) + return 1 + + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/windows_serial_control.hpp b/tools/windows_serial_control.hpp new file mode 100644 index 000000000..d14dc6f8c --- /dev/null +++ b/tools/windows_serial_control.hpp @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif + +namespace ar::iec61850::tools { + +class WindowsSerialControlPort final { +public: + WindowsSerialControlPort() = default; + ~WindowsSerialControlPort() { close(); } + + WindowsSerialControlPort(const WindowsSerialControlPort&) = delete; + WindowsSerialControlPort& operator=(const WindowsSerialControlPort&) = delete; + WindowsSerialControlPort(WindowsSerialControlPort&&) = delete; + WindowsSerialControlPort& operator=(WindowsSerialControlPort&&) = delete; + + [[nodiscard]] bool open(const std::string_view device) { + close(); +#ifdef _WIN32 + if (device.empty()) { + error_ = "Serial device is empty."; + return false; + } + + std::string path{device}; + if (!path.starts_with("\\\\.\\")) { + path = "\\\\.\\" + path; + } + + handle_ = CreateFileA( + path.c_str(), + GENERIC_READ | GENERIC_WRITE, + 0, + nullptr, + OPEN_EXISTING, + 0, + nullptr); + if (handle_ == INVALID_HANDLE_VALUE) { + const auto code = GetLastError(); + handle_ = nullptr; + error_ = "CreateFile failed for " + std::string{device} + + " (" + describe_windows_error(code) + ")."; + return false; + } + + DCB dcb{}; + dcb.DCBlength = sizeof(dcb); + if (GetCommState(handle_, &dcb) == 0) { + set_last_error("GetCommState failed"); + close(); + return false; + } + dcb.BaudRate = CBR_115200; + dcb.ByteSize = 8U; + dcb.Parity = NOPARITY; + dcb.StopBits = ONESTOPBIT; + dcb.fBinary = TRUE; + dcb.fOutxCtsFlow = FALSE; + dcb.fOutxDsrFlow = FALSE; + dcb.fDtrControl = DTR_CONTROL_DISABLE; + dcb.fDsrSensitivity = FALSE; + dcb.fOutX = FALSE; + dcb.fInX = FALSE; + dcb.fRtsControl = RTS_CONTROL_DISABLE; + if (SetCommState(handle_, &dcb) == 0) { + set_last_error("SetCommState failed"); + close(); + return false; + } + + COMMTIMEOUTS timeouts{}; + timeouts.ReadIntervalTimeout = MAXDWORD; + timeouts.ReadTotalTimeoutMultiplier = 0U; + timeouts.ReadTotalTimeoutConstant = 25U; + timeouts.WriteTotalTimeoutMultiplier = 0U; + timeouts.WriteTotalTimeoutConstant = 1'000U; + if (SetCommTimeouts(handle_, &timeouts) == 0) { + set_last_error("SetCommTimeouts failed"); + close(); + return false; + } + + (void)PurgeComm(handle_, PURGE_RXCLEAR | PURGE_TXCLEAR); + device_ = std::string{device}; + error_.clear(); + return true; +#else + (void)device; + error_ = "Serial injector control is available only on Windows."; + return false; +#endif + } + + void close() noexcept { +#ifdef _WIN32 + if (handle_ != nullptr) { + CloseHandle(handle_); + handle_ = nullptr; + } +#endif + device_.clear(); + } + + [[nodiscard]] bool opened() const noexcept { +#ifdef _WIN32 + return handle_ != nullptr; +#else + return false; +#endif + } + + [[nodiscard]] const std::string& error() const noexcept { return error_; } + [[nodiscard]] const std::string& device() const noexcept { return device_; } + + [[nodiscard]] bool write_line(const std::string_view line) { +#ifdef _WIN32 + if (!opened()) { + error_ = "Serial port is not open."; + return false; + } + + std::string framed{line}; + framed.push_back('\n'); + std::size_t offset = 0U; + while (offset < framed.size()) { + DWORD written = 0U; + const auto remaining = framed.size() - offset; + const auto chunk = remaining > static_cast(MAXDWORD) + ? MAXDWORD + : static_cast(remaining); + if (WriteFile( + handle_, + framed.data() + offset, + chunk, + &written, + nullptr) == 0) { + set_last_error("WriteFile failed"); + return false; + } + if (written == 0U) { + error_ = "Serial write made no progress."; + return false; + } + offset += static_cast(written); + } + return true; +#else + (void)line; + error_ = "Serial injector control is available only on Windows."; + return false; +#endif + } + + [[nodiscard]] bool read_arctrl_line( + std::string& response, + const std::uint32_t timeout_ms = 3'000U) { + response.clear(); +#ifdef _WIN32 + if (!opened()) { + error_ = "Serial port is not open."; + return false; + } + + const auto deadline = std::chrono::steady_clock::now() + + std::chrono::milliseconds{timeout_ms}; + std::string line; + line.reserve(512U); + while (std::chrono::steady_clock::now() < deadline) { + char buffer[128]{}; + DWORD received = 0U; + if (ReadFile( + handle_, + buffer, + static_cast(sizeof(buffer)), + &received, + nullptr) == 0) { + set_last_error("ReadFile failed"); + return false; + } + + if (received == 0U) { + std::this_thread::sleep_for(std::chrono::milliseconds{5}); + continue; + } + + for (DWORD index = 0U; index < received; ++index) { + const auto character = buffer[index]; + if (character == '\r') { + continue; + } + if (character == '\n') { + if (line.starts_with("ARCTRL ")) { + response = line.substr(7U); + error_.clear(); + return true; + } + line.clear(); + continue; + } + if (line.size() < 4'096U) { + line.push_back(character); + } else { + line.clear(); + } + } + } + + error_ = "Timed out waiting for ARCTRL response from " + device_ + + ". Verify that the injector firmware is running and that this COM port is the board UART control port."; + return false; +#else + (void)timeout_ms; + error_ = "Serial injector control is available only on Windows."; + return false; +#endif + } + +private: +#ifdef _WIN32 + [[nodiscard]] static std::string describe_windows_error(const DWORD code) { + switch (code) { + case ERROR_FILE_NOT_FOUND: + return "Windows error 2: COM port not found; verify the current port number"; + case ERROR_ACCESS_DENIED: + return "Windows error 5: access denied; close any serial monitor or other process using the port"; + case ERROR_SEM_TIMEOUT: + return "Windows error 121: semaphore timeout; reconnect the USB-to-UART cable/device, verify the driver, and confirm the COM port still enumerates"; + default: + return "Windows error " + std::to_string(code); + } + } + + void set_last_error(const std::string_view prefix) { + error_ = std::string{prefix} + " (" + describe_windows_error(GetLastError()) + ")."; + } + + HANDLE handle_{}; +#endif + std::string error_; + std::string device_; +}; + +} // namespace ar::iec61850::tools