From c88169d618f9612f983464d6895f592068b7dc76 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 29 Jul 2026 14:57:56 +0700 Subject: [PATCH 1/5] Prioritize report acquisition and cached reconnect for IO FAT --- MainWindow.IoTesting.AutoConnect.cs | 171 ++++++++++++++++++++++++---- 1 file changed, 146 insertions(+), 25 deletions(-) diff --git a/MainWindow.IoTesting.AutoConnect.cs b/MainWindow.IoTesting.AutoConnect.cs index 81eb271..ba6e2d7 100644 --- a/MainWindow.IoTesting.AutoConnect.cs +++ b/MainWindow.IoTesting.AutoConnect.cs @@ -88,15 +88,29 @@ void ReportProgress(string message) try { - ReportProgress($"Connecting {ied.IedName} · {ied.IpAddress}:102"); var usedSavedModel = false; if (!device.IsConnected) { var canUseSavedModel = device.HasDiscoveryCache && device.Signals.Count > 0; - var connected = canUseSavedModel - ? await ConnectUsingSavedModelAsync(device, selectDevice: false) - : await ConnectAndConfigureDeviceAsync(device, openWizard: false, selectDevice: false); - usedSavedModel = connected && canUseSavedModel; + var connected = false; + if (canUseSavedModel) + { + ReportProgress($"Fast reconnect {ied.IedName} · saved endpoint and discovery model"); + connected = await ConnectUsingSavedModelAsync(device, selectDevice: false); + usedSavedModel = connected; + if (!connected) + { + ReportProgress("Saved-model reconnect failed · running one full live discovery"); + connected = await ConnectAndConfigureDeviceAsync(device, openWizard: false, selectDevice: false); + usedSavedModel = false; + } + } + else + { + ReportProgress($"Connecting {ied.IedName} · {ied.IpAddress}:102"); + connected = await ConnectAndConfigureDeviceAsync(device, openWizard: false, selectDevice: false); + } + if (!connected) { _ioTestLiveBindingService.Bind(project, Devices); @@ -114,6 +128,10 @@ void ReportProgress(string message) return IoTestSessionActionResult.Failure($"Full live-model discovery failed for {ied.IedName}."); } } + else + { + ReportProgress($"{ied.IedName} association ready · reusing the loaded model"); + } ReportProgress($"Matching {requestedPoints.Count} workbook signal(s)"); var selection = _ioTestSignalSelectionService.Resolve(ied, device); @@ -160,13 +178,13 @@ void ReportProgress(string message) if (device.IsMonitoring && (selectionChanged || !allRequestedPointsLive)) { - ReportProgress("Refreshing acquisition for the workbook scope"); + ReportProgress("Refreshing report acquisition for the workbook scope"); await StopDeviceMonitorAsync(device); } if (!device.IsMonitoring) { - ReportProgress("Arming configured RCB · dynamic URCB fallback if coverage is missing"); + ReportProgress("Arming static RCB first · dynamic DataSet/URCB for uncovered points"); if (!await StartDeviceMonitorAsync(device, navigateToExplorer: false)) { _ioTestLiveBindingService.Bind(project, Devices); @@ -175,7 +193,12 @@ void ReportProgress(string message) } } - await WaitForIoFatAcquisitionAsync(device, ReportProgress); + var acquisition = await SettleIoFatReportPriorityAsync( + project, + requestedPoints, + device, + ReportProgress, + allowSingleRestart: true); var binding = _ioTestLiveBindingService.Bind(project, Devices); var liveCount = requestedPoints.Count(point => @@ -192,12 +215,15 @@ void ReportProgress(string message) SaveSignalSelectionMemory(device); var modelText = usedSavedModel ? "saved model" : "live model"; - var message = $"{ied.IedName} · {liveCount}/{requestedPoints.Count} live · {device.AcquisitionMode}"; + var acquisitionText = acquisition.PollingCount == 0 + ? $"report-backed {acquisition.ReportCount}/{requestedPoints.Count}" + : $"report-backed {acquisition.ReportCount}/{requestedPoints.Count} · MMS fallback {acquisition.PollingCount}"; + var message = $"{ied.IedName} · {liveCount}/{requestedPoints.Count} live · {acquisitionText}"; SetStatus(message); AddLog( - "INFO", + acquisition.PollingCount == 0 ? "INFO" : "WARN", "IO Testing", - $"{message}. Acquisition policy: configured RCB → temporary dynamic DataSet/URCB → bounded MMS verification/fallback. No process control commands are enabled. Project live-bound={binding.LivePointCount}; model={modelText}."); + $"{message}. Acquisition policy: configured RCB → temporary dynamic DataSet/URCB → bounded MMS verification/fallback. No process control commands are enabled. Project live-bound={binding.LivePointCount}; model={modelText}; mode={device.AcquisitionMode}."); ReportProgress(message); return IoTestSessionActionResult.Success(message); } @@ -222,25 +248,120 @@ void ReportProgress(string message) } } - private static async Task WaitForIoFatAcquisitionAsync( + private async Task SettleIoFatReportPriorityAsync( + IoTestProject project, + IReadOnlyCollection requestedPoints, + Iec61850MonitorDevice device, + Action reportProgress, + bool allowSingleRestart) + { + var first = await ObserveIoFatAcquisitionAsync( + project, + requestedPoints, + device, + reportProgress, + TimeSpan.FromSeconds(8)); + + if (!allowSingleRestart || + first.PollingCount == 0 || + !device.AllowDynamicDataSetWrites || + !device.IsMonitoring) + { + return first; + } + + reportProgress($"{first.PollingCount} point(s) still on MMS fallback · rebuilding the report plan once"); + await StopDeviceMonitorAsync(device); + await Task.Delay(180); + if (!await StartDeviceMonitorAsync(device, navigateToExplorer: false)) + return first; + + return await ObserveIoFatAcquisitionAsync( + project, + requestedPoints, + device, + reportProgress, + TimeSpan.FromSeconds(10)); + } + + private async Task ObserveIoFatAcquisitionAsync( + IoTestProject project, + IReadOnlyCollection requestedPoints, Iec61850MonitorDevice device, - Action reportProgress) + Action reportProgress, + TimeSpan timeout) { - if (!device.IsMonitoring) - return; + var deadline = DateTime.UtcNow + timeout; + var last = ReadIoFatAcquisitionSummary(requestedPoints, device); + var announced = false; - for (var attempt = 0; attempt < 35; attempt++) + while (DateTime.UtcNow < deadline && device.IsMonitoring) { - var mode = device.AcquisitionMode ?? string.Empty; - if (!mode.Contains("arming", StringComparison.OrdinalIgnoreCase) && - !mode.Contains("live start", StringComparison.OrdinalIgnoreCase)) - return; - - if (attempt == 0) - reportProgress("Validating RCB/DataSet coverage · MMS remains verification only"); - await Task.Delay(100); + await Task.Delay(120); + _ioTestLiveBindingService.Bind(project, Devices); + last = ReadIoFatAcquisitionSummary(requestedPoints, device); + + if (!announced) + { + reportProgress("Validating static/dynamic report coverage · MMS is fallback only"); + announced = true; + } + + var plannerSettled = !ContainsPlannerStage(device.AcquisitionMode); + if (last.LiveCount == requestedPoints.Count && + plannerSettled && + last.UnknownCount == 0 && + (last.PollingCount == 0 || DateTime.UtcNow >= deadline - TimeSpan.FromSeconds(2))) + { + break; + } + } + + return last; + } + + private static IoFatAcquisitionSummary ReadIoFatAcquisitionSummary( + IEnumerable requestedPoints, + Iec61850MonitorDevice device) + { + var live = 0; + var report = 0; + var polling = 0; + var unknown = 0; + + foreach (var point in requestedPoints) + { + if (point.LiveBindingState == IoTestLiveBindingState.LivePointReady) + live++; + + var source = point.Runtime.CurrentSource ?? string.Empty; + if (IsReportSource(source)) + report++; + else if (source.Contains("poll", StringComparison.OrdinalIgnoreCase) || + source.Contains("MMS", StringComparison.OrdinalIgnoreCase)) + polling++; + else + unknown++; } - reportProgress($"Acquisition still settling · {device.AcquisitionMode}"); + return new IoFatAcquisitionSummary(live, report, polling, unknown, device.AcquisitionMode ?? string.Empty); } + + private static bool IsReportSource(string source) + => source.Contains("BRCB", StringComparison.OrdinalIgnoreCase) || + source.Contains("URCB", StringComparison.OrdinalIgnoreCase) || + source.Contains("report", StringComparison.OrdinalIgnoreCase); + + private static bool ContainsPlannerStage(string? mode) + => (mode ?? string.Empty).Contains("arming", StringComparison.OrdinalIgnoreCase) || + (mode ?? string.Empty).Contains("live start", StringComparison.OrdinalIgnoreCase) || + (mode ?? string.Empty).Contains("preparing", StringComparison.OrdinalIgnoreCase) || + (mode ?? string.Empty).Contains("settling", StringComparison.OrdinalIgnoreCase); + + private sealed record IoFatAcquisitionSummary( + int LiveCount, + int ReportCount, + int PollingCount, + int UnknownCount, + string Mode); } From 436c28d1b876ef139e3f3825a125f98e09bb6aca Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 29 Jul 2026 14:59:01 +0700 Subject: [PATCH 2/5] Add per-IED IO FAT report preview renderer --- .../IoTesting/IoFatReportPreviewService.cs | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 Services/IoTesting/IoFatReportPreviewService.cs diff --git a/Services/IoTesting/IoFatReportPreviewService.cs b/Services/IoTesting/IoFatReportPreviewService.cs new file mode 100644 index 0000000..80c46fb --- /dev/null +++ b/Services/IoTesting/IoFatReportPreviewService.cs @@ -0,0 +1,130 @@ +// Copyright 2026 Ari Sulistiono +// SPDX-License-Identifier: Apache-2.0 + +using System.Globalization; +using System.Net; +using System.Text; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Builds the in-workspace per-IED report preview. The preview follows the same +/// NavigateToString/WebBrowser pattern used by the project-owned ARIEC60870 report +/// preview while official PDF export remains the native PDF 1.4 writer. +/// +public static class IoFatReportPreviewService +{ + public static IoTestProject CreateIedScopedProject(IoTestProject project, IoTestIedPlan ied) + { + ArgumentNullException.ThrowIfNull(project); + ArgumentNullException.ThrowIfNull(ied); + if (!project.Ieds.Contains(ied)) + throw new ArgumentException("The selected IED does not belong to this IO FAT project.", nameof(ied)); + + return new IoTestProject + { + ProjectId = project.ProjectId, + SchemaVersion = project.SchemaVersion, + ProjectName = project.ProjectName, + SourceWorkbookName = project.SourceWorkbookName, + SourceWorkbookSha256 = project.SourceWorkbookSha256, + ImportedAt = project.ImportedAt, + Ieds = new List { ied } + }; + } + + public static string BuildHtml( + IoTestProject project, + IoTestIedPlan ied, + bool draft, + DateTimeOffset? generatedAt = null) + { + ArgumentNullException.ThrowIfNull(project); + ArgumentNullException.ThrowIfNull(ied); + var created = generatedAt ?? DateTimeOffset.Now; + var enabled = ied.TestPoints.Where(point => point.TestEnabled).ToList(); + var passed = enabled.Count(point => point.Runtime.State == IoTestPointState.Passed); + var review = enabled.Count(point => point.Runtime.State == IoTestPointState.Review); + var failed = enabled.Count(point => point.Runtime.State == IoTestPointState.Failed); + var pending = Math.Max(0, enabled.Count - passed - review - failed); + + var html = new StringBuilder(24_000); + html.AppendLine(""); + html.AppendLine($"{E(ied.IedName)} - ARSAS IO FAT Preview"); + html.AppendLine("
"); + html.AppendLine("
"); + html.AppendLine("
ARSAS · IEC 61850 IO LIST FAT
"); + html.AppendLine($"
{E(ied.IedName)} evidence report
"); + html.AppendLine($"
{E(project.ProjectName)} · relay-timestamped OFF → ON → OFF verification
"); + html.AppendLine(draft + ? "
PREVIEW STATUSDRAFT / LIVEStop the active session before issuing the final PDF.
" + : "
PREVIEW STATUSSEALED VIEWReady for per-IED PDF export.
"); + html.AppendLine("
"); + + html.AppendLine("
"); + Meta(html, "IED", ied.IedName); + Meta(html, "IP ADDRESS", ied.IpAddress); + Meta(html, "PROJECT ID", project.ProjectId); + Meta(html, "GENERATED", created.ToString("yyyy-MM-dd HH:mm:ss zzz", CultureInfo.InvariantCulture)); + html.AppendLine("
"); + + html.AppendLine("
"); + Metric(html, "TEST POINTS", enabled.Count, string.Empty); + Metric(html, "PASS", passed, "pass"); + Metric(html, "REVIEW", review, "review"); + Metric(html, "FAILED", failed, "fail"); + Metric(html, "PENDING", pending, "pending"); + html.AppendLine("
"); + + html.AppendLine(""); + html.AppendLine(""); + foreach (var point in enabled) + { + var result = Result(point.Runtime.State); + html.AppendLine(""); + Cell(html, point.SignalName); + Cell(html, point.ObjectReference, "mono"); + Cell(html, $"{point.Runtime.CurrentValue} · {point.Runtime.CurrentQuality}"); + Cell(html, point.Runtime.CurrentSource); + Cell(html, point.Runtime.OnRelayTimestampText, "mono"); + Cell(html, point.Runtime.OffRelayTimestampText, "mono"); + Cell(html, result.Text, $"result {result.Css}"); + Cell(html, $"{point.Runtime.StateText} · {point.Runtime.StatusReason}", "muted"); + html.AppendLine(""); + } + if (enabled.Count == 0) + html.AppendLine(""); + html.AppendLine("
SIGNALIEC 61850 REFERENCEVALUEACQUISITIONON · RELAY TIMEOFF · RELAY TIMERESULTSTATUS / REASON
No enabled IO-list test points are available for this IED.
"); + html.AppendLine($"
Workbook: {E(project.SourceWorkbookName)} · SHA-256 {E(ShortHash(project.SourceWorkbookSha256))}ARSAS per-IED print preview
"); + html.AppendLine("
"); + return html.ToString(); + } + + private static void Meta(StringBuilder html, string label, string value) + => html.AppendLine($"
{E(label)}
{E(value)}
"); + + private static void Metric(StringBuilder html, string label, int value, string css) + => html.AppendLine($"
{E(label)}{value.ToString(CultureInfo.InvariantCulture)}
"); + + private static void Cell(StringBuilder html, string? value, string css = "") + => html.AppendLine($"{E(string.IsNullOrWhiteSpace(value) ? "—" : value)}"); + + private static (string Text, string Css) Result(IoTestPointState state) => state switch + { + IoTestPointState.Passed => ("✔ PASS", "pass"), + IoTestPointState.Review => ("⚠ REVIEW", "review"), + IoTestPointState.Failed => ("✖ FAILED", "fail"), + _ => ("—", "pending") + }; + + private static string E(string? value) => WebUtility.HtmlEncode(value ?? string.Empty); + + private static string ShortHash(string? value) + { + var normalized = value?.Trim() ?? string.Empty; + return normalized.Length <= 16 ? normalized : normalized[..16] + "…"; + } +} From 95d908a35b282968b2f87fc7a5db219572f2506c Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 29 Jul 2026 15:00:04 +0700 Subject: [PATCH 3/5] Port ARIEC-style per-IED print preview into IO FAT workspace --- IoListTestingWindow.PrintPreview.cs | 317 ++++++++++++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 IoListTestingWindow.PrintPreview.cs diff --git a/IoListTestingWindow.PrintPreview.cs b/IoListTestingWindow.PrintPreview.cs new file mode 100644 index 0000000..1db01c0 --- /dev/null +++ b/IoListTestingWindow.PrintPreview.cs @@ -0,0 +1,317 @@ +using System.ComponentModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Threading; +using ArIED61850Tester.Services.IoTesting; +using Microsoft.Win32; + +namespace ArIED61850Tester; + +public partial class IoListTestingWindow +{ + private bool _printPreviewInstalled; + private bool _printPreviewActive; + private DataGrid? _signalWorkspaceGrid; + private Grid? _printPreviewHost; + private WebBrowser? _printPreviewBrowser; + private Button? _printPreviewToggle; + private TextBlock? _printPreviewTitle; + private TextBlock? _printPreviewSubtitle; + private DispatcherTimer? _preparationStateGuard; + + protected override void OnContentRendered(EventArgs e) + { + base.OnContentRendered(e); + if (_printPreviewInstalled) + return; + + InstallPerIedPrintPreview(); + InstallPreparationStateGuard(); + PropertyChanged += PrintPreviewWindow_PropertyChanged; + Session.PropertyChanged += PrintPreviewSession_PropertyChanged; + Closed += PrintPreviewWindow_Closed; + _printPreviewInstalled = true; + } + + private void InstallPerIedPrintPreview() + { + if (Content is not Grid root) + return; + + var middle = root.Children + .OfType() + .FirstOrDefault(child => Grid.GetRow(child) == 2); + var workspaceBorder = middle?.Children + .OfType() + .FirstOrDefault(child => Grid.GetColumn(child) == 2); + if (workspaceBorder?.Child is not Grid workspaceGrid) + return; + + _signalWorkspaceGrid = workspaceGrid.Children.OfType().FirstOrDefault(); + if (_signalWorkspaceGrid == null) + return; + + RemoveMainPreparationSurface(workspaceGrid); + InstallPreviewToggle(root); + _printPreviewHost = BuildPrintPreviewHost(); + Grid.SetRow(_printPreviewHost, Grid.GetRow(_signalWorkspaceGrid)); + workspaceGrid.Children.Add(_printPreviewHost); + } + + private static void RemoveMainPreparationSurface(Grid workspaceGrid) + { + var preparationSurface = workspaceGrid.Children + .OfType() + .FirstOrDefault(border => Grid.GetRow(border) == 1 && + border.Descendants().Any(progress => progress.IsIndeterminate)); + if (preparationSurface != null) + workspaceGrid.Children.Remove(preparationSurface); + + if (workspaceGrid.RowDefinitions.Count > 1) + workspaceGrid.RowDefinitions[1].Height = new GridLength(0); + if (workspaceGrid.RowDefinitions.Count > 2) + workspaceGrid.RowDefinitions[2].Height = new GridLength(10); + } + + private void InstallPreviewToggle(Grid root) + { + var headerBorder = root.Children + .OfType() + .FirstOrDefault(child => Grid.GetRow(child) == 0); + var headerGrid = headerBorder?.Child as Grid; + var actions = headerGrid?.Children.OfType().FirstOrDefault(); + if (actions == null) + return; + + _printPreviewToggle = BuildButton("Print Preview", TogglePrintPreview_Click, primary: false); + _printPreviewToggle.ToolTip = "Preview the selected IED report in the workspace"; + var pdfButtonIndex = actions.Children + .OfType