diff --git a/.github/workflows/validate-sv-evidence.yml b/.github/workflows/validate-sv-evidence.yml new file mode 100644 index 000000000..b05239cab --- /dev/null +++ b/.github/workflows/validate-sv-evidence.yml @@ -0,0 +1,94 @@ +name: Validate SV evidence bundles + +on: + pull_request: + paths: + - "SmvViewerWindow.xaml" + - "Services/SmvViewerWindow.P1Evidence.cs" + - "Services/SmvSnapshotEvidenceExporter.cs" + - "Services/SmvSnapshotCaptureService.cs" + - "tests/ARSAS.Tests/**" + - "engines/ARIEC61850.lock.json" + - ".github/workflows/validate-sv-evidence.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate-evidence: + name: Build and test deterministic SV evidence + runs-on: windows-latest + + steps: + - name: Checkout ARSAS application + uses: actions/checkout@v4 + with: + path: ARSAS + + - name: Resolve immutable ARIEC61850 engine lock + shell: powershell + run: | + $lock = Get-Content ".\ARSAS\engines\ARIEC61850.lock.json" -Raw | ConvertFrom-Json + if ($lock.repository -notmatch '^[^/]+/[^/]+$' -or $lock.commit -notmatch '^[0-9a-f]{40}$') { + throw "ARIEC61850 lock metadata is invalid." + } + "ARIEC61850_REPOSITORY=$($lock.repository)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "ARIEC61850_COMMIT=$($lock.commit)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Checkout immutable ARIEC61850 engine revision + shell: powershell + run: | + git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARIEC61850_REPOSITORY.git" ARIEC61850 + git -C .\ARIEC61850 fetch --quiet --depth 1 origin $env:ARIEC61850_COMMIT + git -C .\ARIEC61850 checkout --quiet --detach $env:ARIEC61850_COMMIT + $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim() + if ($actual -ne $env:ARIEC61850_COMMIT) { + throw "ARIEC61850 pin mismatch. Expected $env:ARIEC61850_COMMIT, got $actual." + } + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Restore focused test project + run: dotnet restore .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj + + - name: Build focused test project + shell: powershell + run: | + $log = ".\ARSAS\sv-evidence-build.log" + dotnet build .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-restore *> $log + $buildExitCode = $LASTEXITCODE + if ($buildExitCode -ne 0) { + Write-Host "=== Focused compiler errors ===" -ForegroundColor Red + $errors = @(Select-String -Path $log -Pattern ': error ') + if ($errors.Count -eq 0) { + Get-Content $log -Tail 80 | ForEach-Object { Write-Host $_ -ForegroundColor Red } + } + else { + $errors | ForEach-Object { Write-Host $_.Line -ForegroundColor Red } + } + exit $buildExitCode + } + Get-Content $log -Tail 12 + + - name: Upload focused compiler diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: ARSAS-sv-evidence-compiler-log + path: ARSAS/sv-evidence-build.log + if-no-files-found: error + + - name: Run SV evidence regression tests + run: dotnet test .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --filter "FullyQualifiedName~SmvSnapshotEvidenceExporterTests" --logger "trx;LogFileName=sv-evidence-tests.trx" --results-directory .\ARSAS\TestResults + + - name: Upload focused evidence test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: ARSAS-sv-evidence-test-results + path: ARSAS/TestResults/*.trx + if-no-files-found: ignore diff --git a/ArIED61850Tester.csproj b/ArIED61850Tester.csproj index d57b561bc..e3efb5d96 100644 --- a/ArIED61850Tester.csproj +++ b/ArIED61850Tester.csproj @@ -13,7 +13,7 @@ Copyright (C) 2026 Ari Sulistiono ARSAS ARSAS - IEC 61850 Engineering Workstation - Open-source Windows IEC 61850 engineering workstation for MMS model discovery, reporting, independent multi-IED monitoring, GOOSE subscription, fault-record file transfer, Sampled Values engineering, SCL workflows, diagnostics, sequence of events, and guarded control validation. + Open-source Windows IEC 61850 engineering workstation for MMS model discovery, reporting, independent multi-IED monitoring, GOOSE subscription, fault-record file transfer, Sampled Values engineering and evidence export, SCL workflows, diagnostics, sequence of events, and guarded control validation. 1.6.19 1.6.19.0 1.6.19.0 @@ -21,7 +21,7 @@ https://github.com/masarray/arsas git README.md - iec61850;iec-61850;mms;goose;sampled-values;smv;iec-61850-file-transfer;comtrade;scl;scl-generator;ied-explorer;multi-ied-monitoring;relay-testing;substation-automation;digital-substation;report-control-block;sequence-of-events;smart-control;wpf;dotnet + iec61850;iec-61850;mms;goose;sampled-values;smv;sv-evidence;iec-61850-file-transfer;comtrade;scl;scl-generator;ied-explorer;multi-ied-monitoring;relay-testing;substation-automation;digital-substation;report-control-block;sequence-of-events;smart-control;wpf;dotnet GPL-3.0-or-later $(ARIEC61850_PROJECT) ..\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj @@ -41,6 +41,9 @@ + @@ -56,4 +59,4 @@ - + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 2afcd257e..f9a200be4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,14 +14,23 @@ Notable public changes to ARSAS are recorded here. Application releases must ide ### Added - `ARSAS.Tests`, the first application-layer regression-test project. -- Regression coverage for clean and incomplete SV windows, gap/duplicate/out-of-order/restart handling, continuity evidence, and immutable stream-selection identity. +- Regression coverage for clean and incomplete SV windows, gap/duplicate/out-of-order/restart handling, continuity evidence, immutable stream-selection identity, and deterministic evidence export. - Canonical `Directory.Build.props` version metadata aligned with the project, `VERSION`, packaging, and CI. - Test-result artifacts in the Windows build workflow. +- Portable Sampled Values engineering evidence bundles containing: + - structured `manifest.json` capture identity and verdict; + - separate application/engine `provenance.json`; + - invariant-culture raw `samples.csv`; + - rendered `waveform.png`; + - explicit `diagnostics.txt` and evidence boundary; + - deterministic `SHA256SUMS.txt` integrity records. +- Runtime publication of the immutable `ARIEC61850.lock.json` provenance used by exported evidence. ### Changed - Windows CI restores and builds the complete solution, runs application regression tests, and only then publishes the portable package. -- Development version advanced to `1.6.19`. The currently published stable release remains `1.6.18` until a separately validated and tagged release is produced. +- The SV viewer can export a reviewed evidence package after a bounded snapshot without claiming current, voltage, engineering units, calibration, formal conformance, or universal interoperability. +- Development version remains `1.6.19`. The currently published stable release remains `1.6.18` until a separately validated and tagged release is produced. ## 1.6.18 @@ -55,4 +64,4 @@ Notable public changes to ARSAS are recorded here. Application releases must ide ## Historical licensing boundary -Revisions through `0df1007d9538b978edba67218136bc5c4f8019ad` remain available under their original terms on branch `archive/apache-2.0-final`. Current `main` and current community release packages are GPL-3.0-or-later only. See `docs/LICENSING.md`. +Revisions through `0df1007d9538b978edba67218136bc5c4f8019ad` remain available under their original terms on branch `archive/apache-2.0-final`. Current `main` and current community release packages are GPL-3.0-or-later only. See `docs/LICENSING.md`. \ No newline at end of file diff --git a/Services/SmvSnapshotEvidenceExporter.cs b/Services/SmvSnapshotEvidenceExporter.cs new file mode 100644 index 000000000..5cbbcc81b --- /dev/null +++ b/Services/SmvSnapshotEvidenceExporter.cs @@ -0,0 +1,428 @@ +using System.Globalization; +using System.IO.Compression; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace ArIED61850Tester.Services; + +public sealed record SmvSnapshotEvidenceProvenance +{ + public string ApplicationName { get; init; } = "ARSAS"; + public string ApplicationVersion { get; init; } = string.Empty; + public string ApplicationInformationalVersion { get; init; } = string.Empty; + public string ApplicationRepository { get; init; } = "masarray/arsas"; + public string EngineRepository { get; init; } = string.Empty; + public string EngineRef { get; init; } = string.Empty; + public string EngineCommit { get; init; } = string.Empty; + public int? EnginePullRequest { get; init; } + + public static SmvSnapshotEvidenceProvenance LoadCurrent(string? baseDirectory = null) + { + var assembly = typeof(SmvSnapshotEvidenceProvenance).Assembly; + var version = assembly.GetName().Version?.ToString(3) ?? string.Empty; + var informationalVersion = assembly + .GetCustomAttribute()? + .InformationalVersion ?? version; + + var provenance = new SmvSnapshotEvidenceProvenance + { + ApplicationVersion = version, + ApplicationInformationalVersion = informationalVersion + }; + + var root = string.IsNullOrWhiteSpace(baseDirectory) ? AppContext.BaseDirectory : baseDirectory; + var candidates = new[] + { + Path.Combine(root, "engines", "ARIEC61850.lock.json"), + Path.Combine(Directory.GetCurrentDirectory(), "engines", "ARIEC61850.lock.json") + }; + + foreach (var candidate in candidates.Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (!File.Exists(candidate)) + continue; + + try + { + using var document = JsonDocument.Parse(File.ReadAllText(candidate, Encoding.UTF8)); + var rootElement = document.RootElement; + return provenance with + { + EngineRepository = ReadString(rootElement, "repository"), + EngineRef = ReadString(rootElement, "ref"), + EngineCommit = ReadString(rootElement, "commit"), + EnginePullRequest = ReadInt32(rootElement, "sourcePullRequest") ?? + ReadInt32(rootElement, "pairedPullRequest") + }; + } + catch (JsonException) + { + // Export remains available, but the manifest will clearly show missing engine provenance. + } + } + + return provenance; + } + + private static string ReadString(JsonElement element, string propertyName) + => element.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() ?? string.Empty + : string.Empty; + + private static int? ReadInt32(JsonElement element, string propertyName) + => element.TryGetProperty(propertyName, out var value) && value.TryGetInt32(out var number) + ? number + : null; +} + +public sealed record SmvSnapshotEvidenceContext +{ + public required DateTimeOffset GeneratedAtUtc { get; init; } + public required string DeviceName { get; init; } + public required string EndpointText { get; init; } + public required string AdapterDisplayText { get; init; } + public required string ControlReference { get; init; } + public required string SelectedStreamId { get; init; } + public required string SelectedDataSetReference { get; init; } + public required string SelectedAppId { get; init; } + public required string SelectedDestinationMac { get; init; } + public required double ExplicitNominalFrequencyHz { get; init; } + public required SmvSnapshotEvidenceProvenance Provenance { get; init; } +} + +public sealed record SmvSnapshotEvidenceBundleResult +{ + public required string BundlePath { get; init; } + public required string BundleSha256 { get; init; } + public required IReadOnlyList Entries { get; init; } +} + +/// +/// Produces one portable, deterministic and reviewable evidence package for a bounded SV snapshot. +/// The package deliberately preserves raw values and safety boundaries; it never invents current, +/// voltage, phase or engineering-unit semantics without trusted ordered SCL mapping. +/// +public static class SmvSnapshotEvidenceExporter +{ + public const string SchemaVersion = "arsas.sv-evidence.v1"; + private static readonly DateTimeOffset FixedZipTimestamp = + new(1980, 1, 1, 0, 0, 0, TimeSpan.Zero); + + public static async Task ExportAsync( + string destinationZipPath, + SmvSnapshotResult snapshot, + SmvSnapshotEvidenceContext context, + ReadOnlyMemory waveformPng, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(destinationZipPath); + ArgumentNullException.ThrowIfNull(snapshot); + ArgumentNullException.ThrowIfNull(context); + if (waveformPng.IsEmpty) + throw new ArgumentException("A rendered waveform PNG is required for the SV evidence bundle.", nameof(waveformPng)); + + var fullDestinationPath = Path.GetFullPath(destinationZipPath); + var destinationDirectory = Path.GetDirectoryName(fullDestinationPath) + ?? throw new InvalidOperationException("The evidence bundle destination has no parent directory."); + Directory.CreateDirectory(destinationDirectory); + + var files = new SortedDictionary(StringComparer.Ordinal) + { + ["README.txt"] = Utf8(BuildReadme(snapshot, context)), + ["diagnostics.txt"] = Utf8(BuildDiagnostics(snapshot)), + ["manifest.json"] = Json(BuildManifest(snapshot, context)), + ["provenance.json"] = Json(context.Provenance), + ["samples.csv"] = Utf8(BuildSamplesCsv(snapshot)), + ["waveform.png"] = waveformPng.ToArray() + }; + files["SHA256SUMS.txt"] = Utf8(BuildChecksums(files)); + + var temporaryPath = Path.Combine( + destinationDirectory, + $".{Path.GetFileName(fullDestinationPath)}.{Guid.NewGuid():N}.tmp"); + + try + { + await using (var output = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.ReadWrite, + FileShare.None, + 131072, + FileOptions.Asynchronous | FileOptions.SequentialScan)) + using (var archive = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: false, Encoding.UTF8)) + { + foreach (var file in files) + { + cancellationToken.ThrowIfCancellationRequested(); + var entry = archive.CreateEntry(file.Key, CompressionLevel.Optimal); + entry.LastWriteTime = FixedZipTimestamp; + await using var stream = entry.Open(); + await stream.WriteAsync(file.Value, cancellationToken).ConfigureAwait(false); + } + } + + File.Move(temporaryPath, fullDestinationPath, overwrite: true); + return new SmvSnapshotEvidenceBundleResult + { + BundlePath = fullDestinationPath, + BundleSha256 = await ComputeFileSha256Async(fullDestinationPath, cancellationToken).ConfigureAwait(false), + Entries = files.Keys.ToArray() + }; + } + finally + { + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + } + } + + public static string BuildSuggestedFileName( + SmvSnapshotEvidenceContext context, + SmvSnapshotResult snapshot) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(snapshot); + + var identity = FirstNonEmpty( + context.SelectedStreamId, + snapshot.StreamId, + context.ControlReference, + $"APPID-{snapshot.AppId:X4}"); + var safeIdentity = SanitizeFileName(identity, 56); + return $"ARSAS-SV-Evidence-{safeIdentity}-{context.GeneratedAtUtc:yyyyMMdd-HHmmss}Z.zip"; + } + + private static object BuildManifest(SmvSnapshotResult snapshot, SmvSnapshotEvidenceContext context) + => new + { + schemaVersion = SchemaVersion, + generatedAtUtc = context.GeneratedAtUtc.ToUniversalTime(), + verdict = snapshot.IsCleanProof ? "PASS" : "REVIEW", + proofBoundary = new + { + proves = "Bounded reception, IEC 61850-9-2 parsing, stable seqOfData shape and observable sample-counter continuity for the selected window.", + doesNotProve = "Calibrated current/voltage accuracy, formal conformance, universal interoperability or channel semantics without trusted ordered SCL mapping and reviewed scaling evidence." + }, + operatorSelection = new + { + context.DeviceName, + context.EndpointText, + context.AdapterDisplayText, + context.ControlReference, + streamId = context.SelectedStreamId, + dataSetReference = context.SelectedDataSetReference, + appId = context.SelectedAppId, + destinationMac = context.SelectedDestinationMac, + context.ExplicitNominalFrequencyHz + }, + observedStream = new + { + appId = $"0x{snapshot.AppId:X4}", + snapshot.SourceMac, + snapshot.DestinationMac, + vlan = snapshot.VlanText, + streamId = snapshot.StreamId, + dataSetReference = snapshot.DataSetReference, + snapshot.ConfigurationRevision, + snapshot.SampleSynchronization, + snapshot.DeclaredSampleRate, + snapshot.DeclaredSampleMode + }, + captureInterval = new + { + startedAtUtc = snapshot.StartedAt.ToUniversalTime(), + completedAtUtc = snapshot.CompletedAt.ToUniversalTime(), + durationMilliseconds = snapshot.CaptureDuration.TotalMilliseconds + }, + timebase = new + { + snapshot.NominalFrequencyHz, + snapshot.SamplesPerCycle, + snapshot.CycleCount, + snapshot.TargetSamples, + snapshot.CapturedSamples, + reason = snapshot.TimebaseReason + }, + transport = new + { + snapshot.CapturedFrames, + snapshot.ParsedAsdus, + firstSampleCount = snapshot.FirstSampleCount, + lastSampleCount = snapshot.LastSampleCount + }, + continuity = new + { + snapshot.ContinuousTransitions, + snapshot.NormalWraps, + snapshot.GapTransitions, + snapshot.MissingSamples, + snapshot.DuplicateTransitions, + snapshot.OutOfOrderTransitions, + snapshot.RestartTransitions, + snapshot.HasCounterAnomaly + }, + payload = new + { + shape = snapshot.PayloadShape, + plottedLaneCount = snapshot.Channels.Count, + lanes = snapshot.Channels.Select(channel => new + { + channel.ChannelIndex, + channel.PayloadWordIndex, + channel.Label, + channel.Interpretation, + sampleCount = channel.Samples.Count, + channel.Minimum, + channel.Maximum, + channel.PeakToPeak + }) + }, + diagnostics = snapshot.Diagnostics, + files = new + { + rawSamples = "samples.csv", + waveform = "waveform.png", + diagnostics = "diagnostics.txt", + provenance = "provenance.json", + checksums = "SHA256SUMS.txt" + } + }; + + private static string BuildSamplesCsv(SmvSnapshotResult snapshot) + { + var builder = new StringBuilder(32768); + builder.Append("sampleIndex,cyclePosition"); + foreach (var channel in snapshot.Channels) + builder.Append(',').Append(CsvCell($"{channel.Label} [raw INT32]")); + builder.AppendLine(); + + var rowCount = snapshot.Channels.Count == 0 + ? 0 + : snapshot.Channels.Max(channel => channel.Samples.Count); + for (var sampleIndex = 0; sampleIndex < rowCount; sampleIndex++) + { + builder.Append(sampleIndex.ToString(CultureInfo.InvariantCulture)); + builder.Append(',').Append( + (sampleIndex / (double)Math.Max(1, snapshot.SamplesPerCycle)) + .ToString("0.########", CultureInfo.InvariantCulture)); + + foreach (var channel in snapshot.Channels) + { + builder.Append(','); + if (sampleIndex < channel.Samples.Count) + builder.Append(channel.Samples[sampleIndex].ToString("R", CultureInfo.InvariantCulture)); + } + builder.AppendLine(); + } + + return builder.ToString(); + } + + private static string BuildDiagnostics(SmvSnapshotResult snapshot) + { + var builder = new StringBuilder(); + builder.AppendLine($"Verdict: {(snapshot.IsCleanProof ? "PASS" : "REVIEW")}"); + builder.AppendLine($"Continuity: gaps={snapshot.GapTransitions}, missing={snapshot.MissingSamples}, duplicates={snapshot.DuplicateTransitions}, outOfOrder={snapshot.OutOfOrderTransitions}, restarts={snapshot.RestartTransitions}"); + builder.AppendLine($"Timebase: {snapshot.TimebaseReason}"); + builder.AppendLine($"Payload: {snapshot.PayloadShape}"); + builder.AppendLine(); + builder.AppendLine("Engine diagnostics:"); + if (snapshot.Diagnostics.Count == 0) + builder.AppendLine("- none"); + else + foreach (var diagnostic in snapshot.Diagnostics) + builder.Append("- ").AppendLine(diagnostic); + return builder.ToString(); + } + + private static string BuildReadme(SmvSnapshotResult snapshot, SmvSnapshotEvidenceContext context) + => $""" + ARSAS Sampled Values Evidence Bundle + ==================================== + + Schema: {SchemaVersion} + Capture started UTC: {snapshot.StartedAt.ToUniversalTime():O} + Capture completed UTC: {snapshot.CompletedAt.ToUniversalTime():O} + Bundle generated UTC: {context.GeneratedAtUtc.ToUniversalTime():O} + Verdict: {(snapshot.IsCleanProof ? "PASS" : "REVIEW")} + + This package records one bounded IEC 61850-9-2 observation window. + Raw lanes are exported exactly as signed numeric payload representations. + + IMPORTANT BOUNDARY + ------------------ + This package proves bounded reception, protocol parsing, stable payload shape and + sample-counter observability for the selected stream. It does not by itself prove + calibrated current/voltage accuracy, formal conformance, universal interoperability, + or IA/IB/IC/IN/VA/VB/VC/VN semantics. Those claims require trusted ordered SCL mapping, + reviewed scaling evidence, known injection and controlled field acceptance. + + Files + ----- + manifest.json Structured capture identity, verdict, counters and payload metadata. + provenance.json ARSAS and ARIEC61850 source provenance. + samples.csv Raw lane samples with invariant-culture numeric formatting. + waveform.png Static visual proof rendered by ARSAS. + diagnostics.txt Continuity and engine diagnostics. + SHA256SUMS.txt SHA-256 digests for every evidence payload file. + """; + + private static string BuildChecksums(IReadOnlyDictionary files) + { + var builder = new StringBuilder(); + foreach (var file in files.OrderBy(item => item.Key, StringComparer.Ordinal)) + builder.Append(Convert.ToHexString(SHA256.HashData(file.Value)).ToLowerInvariant()) + .Append(" ") + .AppendLine(file.Key); + return builder.ToString(); + } + + private static byte[] Json(object value) + => JsonSerializer.SerializeToUtf8Bytes(value, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }); + + private static byte[] Utf8(string value) + => new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(value.Replace("\r\n", "\n", StringComparison.Ordinal)); + + private static string CsvCell(string value) + => $"\"{value.Replace("\"", "\"\"", StringComparison.Ordinal)}\""; + + private static string SanitizeFileName(string value, int maximumLength) + { + var invalid = Path.GetInvalidFileNameChars(); + var sanitized = new string(value + .Trim() + .Select(character => invalid.Contains(character) || char.IsControl(character) ? '-' : character) + .ToArray()); + while (sanitized.Contains("--", StringComparison.Ordinal)) + sanitized = sanitized.Replace("--", "-", StringComparison.Ordinal); + sanitized = sanitized.Trim(' ', '.', '-'); + if (sanitized.Length > maximumLength) + sanitized = sanitized[..maximumLength].TrimEnd(' ', '.', '-'); + return string.IsNullOrWhiteSpace(sanitized) ? "SV-stream" : sanitized; + } + + private static string FirstNonEmpty(params string[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? "SV-stream"; + + private static async Task ComputeFileSha256Async(string path, CancellationToken cancellationToken) + { + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 131072, FileOptions.Asynchronous | FileOptions.SequentialScan); + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + var buffer = new byte[131072]; + while (true) + { + var read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read == 0) + break; + hash.AppendData(buffer, 0, read); + } + return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); + } +} \ No newline at end of file diff --git a/Services/SmvViewerWindow.P1Evidence.cs b/Services/SmvViewerWindow.P1Evidence.cs new file mode 100644 index 000000000..3f70fb779 --- /dev/null +++ b/Services/SmvViewerWindow.P1Evidence.cs @@ -0,0 +1,161 @@ +using System.IO; +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using ArIED61850Tester.Services; +using Microsoft.Win32; + +namespace ArIED61850Tester; + +public partial class SmvViewerWindow +{ + private bool _isEvidenceExportBusy; + private string _p1CaptureAdapterDisplayText = "Unrecorded adapter"; + private double _p1CaptureNominalFrequencyHz = 50.0; + private string _evidenceExportStatusText = + "Capture a snapshot to enable the portable engineering evidence bundle."; + + public bool IsEvidenceExportBusy + { + get => _isEvidenceExportBusy; + private set + { + if (_isEvidenceExportBusy == value) + return; + _isEvidenceExportBusy = value; + Raise(); + } + } + + public string EvidenceExportStatusText + { + get => _evidenceExportStatusText; + private set + { + if (_evidenceExportStatusText == value) + return; + _evidenceExportStatusText = value; + Raise(); + } + } + + + private void CaptureP1EvidenceSelection() + { + _p1CaptureAdapterDisplayText = SelectedAdapter?.DisplayText ?? "Unrecorded adapter"; + _p1CaptureNominalFrequencyHz = ReadSelectedFrequency(); + } + + private async void ExportEvidence_Click(object sender, RoutedEventArgs e) + { + var snapshot = _snapshot; + if (snapshot is null) + { + MessageBox.Show( + this, + "Capture and accept one bounded SV snapshot before exporting evidence.", + "SV Evidence Bundle", + MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } + + var context = new SmvSnapshotEvidenceContext + { + GeneratedAtUtc = DateTimeOffset.UtcNow, + DeviceName = DeviceName, + EndpointText = EndpointText, + AdapterDisplayText = _p1CaptureAdapterDisplayText, + ControlReference = _p0CaptureSelection?.ControlReference ?? string.Empty, + SelectedStreamId = _p0CaptureSelection?.StreamId ?? snapshot.StreamId, + SelectedDataSetReference = _p0CaptureSelection?.DataSetReference ?? snapshot.DataSetReference, + SelectedAppId = _p0CaptureSelection?.AppId ?? $"0x{snapshot.AppId:X4}", + SelectedDestinationMac = _p0CaptureSelection?.DestinationMac ?? snapshot.DestinationMac, + ExplicitNominalFrequencyHz = _p1CaptureNominalFrequencyHz, + Provenance = SmvSnapshotEvidenceProvenance.LoadCurrent() + }; + + var dialog = new SaveFileDialog + { + Title = "Export ARSAS SV evidence bundle", + Filter = "ARSAS SV evidence bundle (*.zip)|*.zip", + DefaultExt = ".zip", + AddExtension = true, + OverwritePrompt = true, + FileName = SmvSnapshotEvidenceExporter.BuildSuggestedFileName(context, snapshot) + }; + if (dialog.ShowDialog(this) != true) + return; + + IsEvidenceExportBusy = true; + ExportEvidenceButton.IsEnabled = false; + EvidenceExportStatusText = "Rendering waveform and building evidence package…"; + + try + { + await Dispatcher.InvokeAsync(RenderWaveform); + var waveformPng = RenderWaveformPng(); + var result = await SmvSnapshotEvidenceExporter.ExportAsync( + dialog.FileName, + snapshot, + context, + waveformPng); + + EvidenceExportStatusText = + $"Evidence exported: {Path.GetFileName(result.BundlePath)} · SHA-256 {result.BundleSha256[..12]}…"; + StatusText = + $"SV evidence bundle exported with {result.Entries.Count:N0} auditable entries. The raw-value and calibration boundaries remain explicit."; + + MessageBox.Show( + this, + $"Evidence bundle created successfully.\n\n{result.BundlePath}\n\nSHA-256\n{result.BundleSha256}", + "SV Evidence Bundle", + MessageBoxButton.OK, + MessageBoxImage.Information); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException) + { + EvidenceExportStatusText = $"Evidence export failed: {ex.Message}"; + StatusText = "No partial evidence package was accepted. Choose a writable destination and retry."; + MessageBox.Show( + this, + ex.Message, + "SV Evidence Export Failed", + MessageBoxButton.OK, + MessageBoxImage.Warning); + } + finally + { + IsEvidenceExportBusy = false; + ExportEvidenceButton.IsEnabled = _snapshot is not null && !CancelButton.IsEnabled; + } + } + + private byte[] RenderWaveformPng() + { + WaveformCanvas.UpdateLayout(); + var renderWidth = WaveformCanvas.ActualWidth > 1 ? WaveformCanvas.ActualWidth : 1000; + var configuredHeight = double.IsNaN(WaveformCanvas.Height) ? 390 : WaveformCanvas.Height; + var renderHeight = WaveformCanvas.ActualHeight > 1 ? WaveformCanvas.ActualHeight : configuredHeight; + var width = Math.Max(1, (int)Math.Ceiling(renderWidth)); + var height = Math.Max(1, (int)Math.Ceiling(renderHeight)); + var bounds = new Rect(0, 0, width, height); + + var visual = new DrawingVisual(); + using (var drawing = visual.RenderOpen()) + { + drawing.DrawRectangle(Brushes.White, null, bounds); + drawing.DrawRectangle(new VisualBrush(WaveformCanvas) { Stretch = Stretch.Fill }, null, bounds); + } + + var bitmap = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32); + bitmap.Render(visual); + bitmap.Freeze(); + + var encoder = new PngBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(bitmap)); + using var output = new MemoryStream(); + encoder.Save(output); + return output.ToArray(); + } +} \ No newline at end of file diff --git a/SmvViewerWindow.P0Hardening.cs b/SmvViewerWindow.P0Hardening.cs index 78e6ee511..df3e2a086 100644 --- a/SmvViewerWindow.P0Hardening.cs +++ b/SmvViewerWindow.P0Hardening.cs @@ -33,6 +33,8 @@ private void P0CaptureButton_Click(object sender, RoutedEventArgs e) stream.DataSetReference, stream.AppId, stream.DestinationMac); + + CaptureP1EvidenceSelection(); } private void P0StreamGrid_SelectionChanged(object sender, SelectionChangedEventArgs e) diff --git a/SmvViewerWindow.xaml b/SmvViewerWindow.xaml index a5dfd6e8c..7475bda66 100644 --- a/SmvViewerWindow.xaml +++ b/SmvViewerWindow.xaml @@ -208,15 +208,37 @@ + + - +