From 1bdd40d18865e3c627b347dbb4c088f7f3b74987 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 15:05:44 +0700 Subject: [PATCH 01/33] feat(fat): add native FAT persistent state model --- Models/NativeFatModels.cs | 285 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 Models/NativeFatModels.cs diff --git a/Models/NativeFatModels.cs b/Models/NativeFatModels.cs new file mode 100644 index 000000000..9f9d874c6 --- /dev/null +++ b/Models/NativeFatModels.cs @@ -0,0 +1,285 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace ArIED61850Tester.Models; + +/// +/// Persistent, acquisition-independent FAT state layered on top of the canonical +/// Engineering/IED Explorer monitor point model. Nothing in this file owns MMS, +/// report-control, polling, or command runtime state. +/// +public sealed class NativeFatDeviceState +{ + public int SchemaVersion { get; set; } = 1; + public string DeviceId { get; set; } = string.Empty; + public string IedName { get; set; } = string.Empty; + public DateTimeOffset CreatedUtc { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedUtc { get; set; } = DateTimeOffset.UtcNow; + public List Signals { get; set; } = new(); + + [System.Text.Json.Serialization.JsonIgnore] + public string StoragePath { get; set; } = string.Empty; +} + +public sealed class NativeFatSignalState +{ + /// + /// Stable per-IED identity: normalized IEC object reference + FC. Display labels + /// are deliberately excluded so a signal rename does not erase commissioning work. + /// + public string Key { get; set; } = string.Empty; + public string SignalName { get; set; } = string.Empty; + public string IecReference { get; set; } = string.Empty; + public string FunctionalConstraint { get; set; } = string.Empty; + public string DataType { get; set; } = string.Empty; + public DateTimeOffset FirstSeenUtc { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset LastSeenUtc { get; set; } = DateTimeOffset.UtcNow; + public bool IsHistorical { get; set; } + public NativeFatCapture? Value1 { get; set; } + public NativeFatCapture? Value2 { get; set; } + public string Result { get; set; } = NativeFatResult.Untested; + public List History { get; set; } = new(); +} + +public sealed class NativeFatCapture +{ + public string Value { get; set; } = "-"; + public string Quality { get; set; } = "Unknown"; + public string DeviceTimestamp { get; set; } = "-"; + public string SourceMode { get; set; } = "Unknown"; + public long Sequence { get; set; } + public DateTimeOffset CapturedUtc { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class NativeFatHistoryEntry +{ + public DateTimeOffset TimestampUtc { get; set; } = DateTimeOffset.UtcNow; + public string Action { get; set; } = string.Empty; + public string Result { get; set; } = string.Empty; + public NativeFatCapture? Value1 { get; set; } + public NativeFatCapture? Value2 { get; set; } + public string Note { get; set; } = string.Empty; +} + +public static class NativeFatResult +{ + public const string Untested = "UNTESTED"; + public const string Pass = "PASS"; + public const string Review = "REVIEW"; + public const string Fail = "FAIL"; +} + +public static class NativeFatIdentity +{ + public static string BuildKey(Iec61850MonitorPoint point) + => BuildKey(point.IecReference, point.FunctionalConstraint); + + public static string BuildKey(string? reference, string? functionalConstraint) + { + var normalized = NormalizeReference(reference); + var fc = (functionalConstraint ?? string.Empty).Trim().ToUpperInvariant(); + return string.IsNullOrWhiteSpace(fc) ? normalized : $"{normalized}|{fc}"; + } + + public static string NormalizeReference(string? reference) + { + var value = (reference ?? string.Empty) + .Trim() + .Replace('$', '.') + .Replace("..", ".", StringComparison.Ordinal); + return value.ToUpperInvariant(); + } +} + +/// +/// A lightweight UI projection that forwards current value/quality directly from the +/// canonical Explorer point while keeping FAT captures/results in a separate persistent +/// state object. Historical rows intentionally have no SourcePoint. +/// +public sealed class NativeFatSignalRow : INotifyPropertyChanged, IDisposable +{ + private Iec61850MonitorPoint? _sourcePoint; + + public NativeFatSignalRow(NativeFatSignalState state, Iec61850MonitorPoint? sourcePoint) + { + State = state ?? throw new ArgumentNullException(nameof(state)); + AttachSource(sourcePoint); + } + + public NativeFatSignalState State { get; } + public Iec61850MonitorPoint? SourcePoint => _sourcePoint; + public string Key => State.Key; + public string SignalName => _sourcePoint?.SignalName ?? State.SignalName; + public string IecReference => _sourcePoint?.IecReference ?? State.IecReference; + public string IecTelegram => _sourcePoint?.IecTelegram ?? State.IecReference; + public string DataType => _sourcePoint?.IecDataType ?? State.DataType; + public string FunctionalConstraint => _sourcePoint?.FunctionalConstraint ?? State.FunctionalConstraint; + public string LiveValue => _sourcePoint?.DisplayValue ?? "-"; + public string Quality => _sourcePoint?.Quality ?? "Historical"; + public string DeviceTimestamp => _sourcePoint?.DeviceTimestamp ?? "-"; + public string Value1Text => State.Value1?.Value ?? "-"; + public string Value2Text => State.Value2?.Value ?? "-"; + public string Result => string.IsNullOrWhiteSpace(State.Result) ? NativeFatResult.Untested : State.Result; + public bool IsHistorical => _sourcePoint == null || State.IsHistorical; + public string StatusText => IsHistorical ? "HISTORICAL" : State.Value1 == null && State.Value2 == null ? "READY" : "CAPTURED"; + public int HistoryCount => State.History?.Count ?? 0; + public string HistoryText => HistoryCount == 0 ? "—" : $"{HistoryCount} record{(HistoryCount == 1 ? string.Empty : "s")}"; + public bool CanCapture => _sourcePoint != null; + + public event PropertyChangedEventHandler? PropertyChanged; + public event EventHandler? StateChanged; + + public void AttachSource(Iec61850MonitorPoint? point) + { + if (ReferenceEquals(_sourcePoint, point)) + return; + if (_sourcePoint != null) + _sourcePoint.PropertyChanged -= SourcePoint_PropertyChanged; + _sourcePoint = point; + if (_sourcePoint != null) + _sourcePoint.PropertyChanged += SourcePoint_PropertyChanged; + RaiseAll(); + } + + public bool CaptureValue(int slot) + { + if (_sourcePoint == null || slot is < 1 or > 2) + return false; + + var capture = Capture(_sourcePoint); + if (slot == 1) + State.Value1 = capture; + else + State.Value2 = capture; + + State.LastSeenUtc = DateTimeOffset.UtcNow; + AppendHistory($"Capture Value {slot}"); + Raise(nameof(Value1Text)); + Raise(nameof(Value2Text)); + Raise(nameof(StatusText)); + Raise(nameof(HistoryCount)); + Raise(nameof(HistoryText)); + StateChanged?.Invoke(this, EventArgs.Empty); + return true; + } + + public void SetResult(string result) + { + result = result switch + { + NativeFatResult.Pass => NativeFatResult.Pass, + NativeFatResult.Fail => NativeFatResult.Fail, + NativeFatResult.Review => NativeFatResult.Review, + _ => NativeFatResult.Untested + }; + if (State.Result.Equals(result, StringComparison.OrdinalIgnoreCase)) + return; + + State.Result = result; + State.LastSeenUtc = DateTimeOffset.UtcNow; + AppendHistory($"Result {result}"); + Raise(nameof(Result)); + Raise(nameof(HistoryCount)); + Raise(nameof(HistoryText)); + StateChanged?.Invoke(this, EventArgs.Empty); + } + + public void ResetCurrentResult() + { + if (State.Value1 == null && State.Value2 == null && + State.Result.Equals(NativeFatResult.Untested, StringComparison.OrdinalIgnoreCase)) + return; + + AppendHistory("Reset current FAT state"); + State.Value1 = null; + State.Value2 = null; + State.Result = NativeFatResult.Untested; + State.LastSeenUtc = DateTimeOffset.UtcNow; + Raise(nameof(Value1Text)); + Raise(nameof(Value2Text)); + Raise(nameof(Result)); + Raise(nameof(StatusText)); + Raise(nameof(HistoryCount)); + Raise(nameof(HistoryText)); + StateChanged?.Invoke(this, EventArgs.Empty); + } + + private static NativeFatCapture Capture(Iec61850MonitorPoint point) + => new() + { + Value = point.DisplayValue, + Quality = point.Quality, + DeviceTimestamp = point.DeviceTimestamp, + SourceMode = point.SourceMode, + Sequence = point.Sequence, + CapturedUtc = DateTimeOffset.UtcNow + }; + + private void AppendHistory(string action) + { + State.History ??= new List(); + State.History.Add(new NativeFatHistoryEntry + { + TimestampUtc = DateTimeOffset.UtcNow, + Action = action, + Result = Result, + Value1 = Clone(State.Value1), + Value2 = Clone(State.Value2) + }); + } + + private static NativeFatCapture? Clone(NativeFatCapture? source) + => source == null ? null : new NativeFatCapture + { + Value = source.Value, + Quality = source.Quality, + DeviceTimestamp = source.DeviceTimestamp, + SourceMode = source.SourceMode, + Sequence = source.Sequence, + CapturedUtc = source.CapturedUtc + }; + + private void SourcePoint_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(Iec61850MonitorPoint.Value): + case nameof(Iec61850MonitorPoint.DisplayValue): + Raise(nameof(LiveValue)); + break; + case nameof(Iec61850MonitorPoint.Quality): + Raise(nameof(Quality)); + break; + case nameof(Iec61850MonitorPoint.DeviceTimestamp): + Raise(nameof(DeviceTimestamp)); + break; + default: + return; + } + } + + private void RaiseAll() + { + Raise(nameof(SignalName)); + Raise(nameof(IecReference)); + Raise(nameof(IecTelegram)); + Raise(nameof(DataType)); + Raise(nameof(FunctionalConstraint)); + Raise(nameof(LiveValue)); + Raise(nameof(Quality)); + Raise(nameof(DeviceTimestamp)); + Raise(nameof(IsHistorical)); + Raise(nameof(StatusText)); + Raise(nameof(CanCapture)); + } + + private void Raise([CallerMemberName] string? propertyName = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + + public void Dispose() + { + if (_sourcePoint != null) + _sourcePoint.PropertyChanged -= SourcePoint_PropertyChanged; + _sourcePoint = null; + } +} From aa09d1990cf451f97e71825f66dd30144de38925 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 15:06:21 +0700 Subject: [PATCH 02/33] feat(fat): add tolerant per-IED JSON state store --- Services/NativeFatStateStore.cs | 286 ++++++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 Services/NativeFatStateStore.cs diff --git a/Services/NativeFatStateStore.cs b/Services/NativeFatStateStore.cs new file mode 100644 index 000000000..29a0ec81c --- /dev/null +++ b/Services/NativeFatStateStore.cs @@ -0,0 +1,286 @@ +using System.Text.Json; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +/// +/// Non-destructive, per-IED FAT persistence. The store never deletes unmatched signal +/// records during reconciliation; removed/changed engineering therefore remains visible +/// as historical commissioning evidence instead of becoming a load error or data loss. +/// +public sealed class NativeFatStateStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true + }; + + private readonly string _rootDirectory; + + public NativeFatStateStore(string? rootDirectory = null) + { + _rootDirectory = string.IsNullOrWhiteSpace(rootDirectory) + ? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ARSAS", + "FAT", + "NativeState") + : rootDirectory; + } + + public string RootDirectory => _rootDirectory; + + public async Task LoadAndReconcileAsync( + Iec61850MonitorDevice device, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(device); + var state = await LoadAsync(device, cancellationToken).ConfigureAwait(false); + Reconcile(state, device); + return state; + } + + public async Task LoadAsync( + Iec61850MonitorDevice device, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(device); + Directory.CreateDirectory(_rootDirectory); + + var preferredPath = GetPreferredPath(device.Name); + var candidate = await TryReadAsync(preferredPath, cancellationToken).ConfigureAwait(false); + if (IsForDevice(candidate, device)) + { + candidate!.StoragePath = preferredPath; + Normalize(candidate, device); + return candidate; + } + + // IED display names can change. Resolve by stable DeviceId before creating a new + // state so a harmless rename cannot strand the operator's previous FAT evidence. + foreach (var path in Directory.EnumerateFiles(_rootDirectory, "*.json", SearchOption.TopDirectoryOnly)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (path.Equals(preferredPath, StringComparison.OrdinalIgnoreCase)) + continue; + + var probed = await TryReadAsync(path, cancellationToken).ConfigureAwait(false); + if (!IsForDevice(probed, device)) + continue; + + probed!.StoragePath = path; + Normalize(probed, device); + return probed; + } + + return new NativeFatDeviceState + { + DeviceId = device.DeviceId, + IedName = device.Name, + CreatedUtc = DateTimeOffset.UtcNow, + UpdatedUtc = DateTimeOffset.UtcNow, + StoragePath = preferredPath + }; + } + + public async Task SaveAsync(NativeFatDeviceState state, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(state); + Directory.CreateDirectory(_rootDirectory); + state.UpdatedUtc = DateTimeOffset.UtcNow; + state.SchemaVersion = Math.Max(1, state.SchemaVersion); + + var path = string.IsNullOrWhiteSpace(state.StoragePath) + ? GetPreferredPath(state.IedName) + : state.StoragePath; + var tempPath = path + ".tmp-" + Guid.NewGuid().ToString("N"); + + try + { + await using (var stream = new FileStream( + tempPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 64 * 1024, + useAsync: true)) + { + await JsonSerializer.SerializeAsync(stream, state, JsonOptions, cancellationToken) + .ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + File.Move(tempPath, path, overwrite: true); + state.StoragePath = path; + } + finally + { + try + { + if (File.Exists(tempPath)) + File.Delete(tempPath); + } + catch + { + // A stale temp file is harmless; never turn cleanup into evidence loss. + } + } + } + + public static void Reconcile(NativeFatDeviceState state, Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(state); + ArgumentNullException.ThrowIfNull(device); + + state.DeviceId = device.DeviceId; + state.IedName = device.Name; + state.Signals ??= new List(); + + var now = DateTimeOffset.UtcNow; + var byKey = state.Signals + .Where(item => !string.IsNullOrWhiteSpace(item.Key)) + .GroupBy(item => item.Key, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + var currentKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var point in device.Points) + { + var key = NativeFatIdentity.BuildKey(point); + if (string.IsNullOrWhiteSpace(key)) + continue; + + currentKeys.Add(key); + if (!byKey.TryGetValue(key, out var saved)) + { + saved = new NativeFatSignalState + { + Key = key, + SignalName = point.SignalName, + IecReference = point.IecReference, + FunctionalConstraint = point.FunctionalConstraint, + DataType = point.IecDataType, + FirstSeenUtc = now, + LastSeenUtc = now, + Result = NativeFatResult.Untested, + IsHistorical = false + }; + state.Signals.Add(saved); + byKey[key] = saved; + } + else + { + // Metadata follows the current engineering model while captures/history + // remain untouched. This is what makes signal display-name changes safe. + saved.SignalName = point.SignalName; + saved.IecReference = point.IecReference; + saved.FunctionalConstraint = point.FunctionalConstraint; + saved.DataType = point.IecDataType; + saved.LastSeenUtc = now; + saved.IsHistorical = false; + saved.History ??= new List(); + if (string.IsNullOrWhiteSpace(saved.Result)) + saved.Result = NativeFatResult.Untested; + } + } + + foreach (var saved in state.Signals) + { + if (!currentKeys.Contains(saved.Key)) + saved.IsHistorical = true; + } + + state.UpdatedUtc = now; + } + + public static IReadOnlyList BuildRows( + NativeFatDeviceState state, + Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(state); + ArgumentNullException.ThrowIfNull(device); + + var current = device.Points + .GroupBy(NativeFatIdentity.BuildKey, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + + return state.Signals + .Select(saved => new NativeFatSignalRow( + saved, + current.TryGetValue(saved.Key, out var point) ? point : null)) + .OrderBy(row => row.IsHistorical) + .ThenBy(row => row.SignalName, StringComparer.OrdinalIgnoreCase) + .ThenBy(row => row.IecReference, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private async Task TryReadAsync(string path, CancellationToken cancellationToken) + { + if (!File.Exists(path)) + return null; + + try + { + await using var stream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 64 * 1024, + useAsync: true); + return await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch + { + // Tolerant by design: a damaged old file must not block engineering. Leave + // the original file untouched so it can still be recovered manually. + return null; + } + } + + private static bool IsForDevice(NativeFatDeviceState? state, Iec61850MonitorDevice device) + { + if (state == null) + return false; + if (!string.IsNullOrWhiteSpace(state.DeviceId) && + state.DeviceId.Equals(device.DeviceId, StringComparison.OrdinalIgnoreCase)) + return true; + + return string.IsNullOrWhiteSpace(state.DeviceId) && + state.IedName.Equals(device.Name, StringComparison.OrdinalIgnoreCase); + } + + private static void Normalize(NativeFatDeviceState state, Iec61850MonitorDevice device) + { + state.SchemaVersion = Math.Max(1, state.SchemaVersion); + state.DeviceId = device.DeviceId; + state.IedName = device.Name; + state.Signals ??= new List(); + foreach (var signal in state.Signals) + { + signal.History ??= new List(); + if (string.IsNullOrWhiteSpace(signal.Key)) + signal.Key = NativeFatIdentity.BuildKey(signal.IecReference, signal.FunctionalConstraint); + if (string.IsNullOrWhiteSpace(signal.Result)) + signal.Result = NativeFatResult.Untested; + } + } + + private string GetPreferredPath(string? iedName) + => Path.Combine(_rootDirectory, SanitizeFileStem(iedName) + ".json"); + + private static string SanitizeFileStem(string? value) + { + var source = string.IsNullOrWhiteSpace(value) ? "IED" : value.Trim(); + var invalid = Path.GetInvalidFileNameChars().ToHashSet(); + var result = new string(source.Select(character => invalid.Contains(character) ? '_' : character).ToArray()) + .Trim() + .Trim('.'); + return string.IsNullOrWhiteSpace(result) ? "IED" : result; + } +} From b4a7cbb06b1e84aeaeb24dd50b82fabf512b5e0c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 15:12:49 +0700 Subject: [PATCH 03/33] refactor(fat): bind native FAT rows to Explorer signal authority --- Models/NativeFatModels.cs | 109 ++++++++++++++++++++++++++------------ 1 file changed, 75 insertions(+), 34 deletions(-) diff --git a/Models/NativeFatModels.cs b/Models/NativeFatModels.cs index 9f9d874c6..f94d7b3a3 100644 --- a/Models/NativeFatModels.cs +++ b/Models/NativeFatModels.cs @@ -5,8 +5,8 @@ namespace ArIED61850Tester.Models; /// /// Persistent, acquisition-independent FAT state layered on top of the canonical -/// Engineering/IED Explorer monitor point model. Nothing in this file owns MMS, -/// report-control, polling, or command runtime state. +/// Engineering/IED Explorer signal model. Nothing here owns MMS, report-control, +/// polling, or command runtime state. /// public sealed class NativeFatDeviceState { @@ -24,7 +24,7 @@ public sealed class NativeFatDeviceState public sealed class NativeFatSignalState { /// - /// Stable per-IED identity: normalized IEC object reference + FC. Display labels + /// Stable per-IED identity: normalized IEC object reference + FC. Display labels /// are deliberately excluded so a signal rename does not erase commissioning work. /// public string Key { get; set; } = string.Empty; @@ -71,6 +71,9 @@ public static class NativeFatResult public static class NativeFatIdentity { + public static string BuildKey(SignalDefinition signal) + => BuildKey(signal.ObjectReference, signal.FunctionalConstraint); + public static string BuildKey(Iec61850MonitorPoint point) => BuildKey(point.IecReference, point.FunctionalConstraint); @@ -92,61 +95,78 @@ public static string NormalizeReference(string? reference) } /// -/// A lightweight UI projection that forwards current value/quality directly from the -/// canonical Explorer point while keeping FAT captures/results in a separate persistent -/// state object. Historical rows intentionally have no SourcePoint. +/// Lightweight FAT projection. Engineering identity and current value come directly +/// from the Explorer's SignalDefinition, with a monitor point used when available for +/// the richer IEC telegram/acquisition metadata. FAT captures/results remain separate. +/// Historical rows intentionally have neither live source. /// public sealed class NativeFatSignalRow : INotifyPropertyChanged, IDisposable { + private SignalDefinition? _sourceSignal; private Iec61850MonitorPoint? _sourcePoint; - public NativeFatSignalRow(NativeFatSignalState state, Iec61850MonitorPoint? sourcePoint) + public NativeFatSignalRow( + NativeFatSignalState state, + SignalDefinition? sourceSignal, + Iec61850MonitorPoint? sourcePoint) { State = state ?? throw new ArgumentNullException(nameof(state)); - AttachSource(sourcePoint); + AttachSources(sourceSignal, sourcePoint); } public NativeFatSignalState State { get; } + public SignalDefinition? SourceSignal => _sourceSignal; public Iec61850MonitorPoint? SourcePoint => _sourcePoint; public string Key => State.Key; - public string SignalName => _sourcePoint?.SignalName ?? State.SignalName; - public string IecReference => _sourcePoint?.IecReference ?? State.IecReference; - public string IecTelegram => _sourcePoint?.IecTelegram ?? State.IecReference; - public string DataType => _sourcePoint?.IecDataType ?? State.DataType; - public string FunctionalConstraint => _sourcePoint?.FunctionalConstraint ?? State.FunctionalConstraint; - public string LiveValue => _sourcePoint?.DisplayValue ?? "-"; - public string Quality => _sourcePoint?.Quality ?? "Historical"; - public string DeviceTimestamp => _sourcePoint?.DeviceTimestamp ?? "-"; + public string SignalName => _sourceSignal?.Name ?? _sourcePoint?.SignalName ?? State.SignalName; + public string IecReference => _sourceSignal?.ObjectReference ?? _sourcePoint?.IecReference ?? State.IecReference; + public string IecTelegram => _sourcePoint?.IecTelegram ?? _sourceSignal?.DisplayReference ?? State.IecReference; + public string DataType => _sourceSignal?.DataType ?? _sourcePoint?.IecDataType ?? State.DataType; + public string FunctionalConstraint => _sourceSignal?.FunctionalConstraint ?? _sourcePoint?.FunctionalConstraint ?? State.FunctionalConstraint; + public string LiveValue => _sourcePoint?.DisplayValue ?? _sourceSignal?.Value ?? "-"; + public string Quality => _sourcePoint?.Quality ?? _sourceSignal?.Quality ?? (IsHistorical ? "Historical" : "Unknown"); + public string DeviceTimestamp => _sourcePoint?.DeviceTimestamp ?? _sourceSignal?.DeviceTimestamp ?? "-"; public string Value1Text => State.Value1?.Value ?? "-"; public string Value2Text => State.Value2?.Value ?? "-"; public string Result => string.IsNullOrWhiteSpace(State.Result) ? NativeFatResult.Untested : State.Result; - public bool IsHistorical => _sourcePoint == null || State.IsHistorical; + public bool IsHistorical => _sourceSignal == null && _sourcePoint == null || State.IsHistorical; public string StatusText => IsHistorical ? "HISTORICAL" : State.Value1 == null && State.Value2 == null ? "READY" : "CAPTURED"; public int HistoryCount => State.History?.Count ?? 0; public string HistoryText => HistoryCount == 0 ? "—" : $"{HistoryCount} record{(HistoryCount == 1 ? string.Empty : "s")}"; - public bool CanCapture => _sourcePoint != null; + public bool CanCapture => _sourceSignal != null || _sourcePoint != null; public event PropertyChangedEventHandler? PropertyChanged; public event EventHandler? StateChanged; - public void AttachSource(Iec61850MonitorPoint? point) + public void AttachSources(SignalDefinition? signal, Iec61850MonitorPoint? point) { - if (ReferenceEquals(_sourcePoint, point)) - return; - if (_sourcePoint != null) - _sourcePoint.PropertyChanged -= SourcePoint_PropertyChanged; - _sourcePoint = point; - if (_sourcePoint != null) - _sourcePoint.PropertyChanged += SourcePoint_PropertyChanged; + if (!ReferenceEquals(_sourceSignal, signal)) + { + if (_sourceSignal != null) + _sourceSignal.PropertyChanged -= SourceSignal_PropertyChanged; + _sourceSignal = signal; + if (_sourceSignal != null) + _sourceSignal.PropertyChanged += SourceSignal_PropertyChanged; + } + + if (!ReferenceEquals(_sourcePoint, point)) + { + if (_sourcePoint != null) + _sourcePoint.PropertyChanged -= SourcePoint_PropertyChanged; + _sourcePoint = point; + if (_sourcePoint != null) + _sourcePoint.PropertyChanged += SourcePoint_PropertyChanged; + } + RaiseAll(); } public bool CaptureValue(int slot) { - if (_sourcePoint == null || slot is < 1 or > 2) + if (!CanCapture || slot is < 1 or > 2) return false; - var capture = Capture(_sourcePoint); + var capture = CaptureCurrent(); if (slot == 1) State.Value1 = capture; else @@ -204,14 +224,14 @@ public void ResetCurrentResult() StateChanged?.Invoke(this, EventArgs.Empty); } - private static NativeFatCapture Capture(Iec61850MonitorPoint point) + private NativeFatCapture CaptureCurrent() => new() { - Value = point.DisplayValue, - Quality = point.Quality, - DeviceTimestamp = point.DeviceTimestamp, - SourceMode = point.SourceMode, - Sequence = point.Sequence, + Value = LiveValue, + Quality = Quality, + DeviceTimestamp = DeviceTimestamp, + SourceMode = _sourcePoint?.SourceMode ?? _sourceSignal?.ReportPlan ?? "Explorer", + Sequence = _sourcePoint?.Sequence ?? 0, CapturedUtc = DateTimeOffset.UtcNow }; @@ -239,6 +259,24 @@ private void AppendHistory(string action) CapturedUtc = source.CapturedUtc }; + private void SourceSignal_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(SignalDefinition.Value): + if (_sourcePoint == null) Raise(nameof(LiveValue)); + break; + case nameof(SignalDefinition.Quality): + if (_sourcePoint == null) Raise(nameof(Quality)); + break; + case nameof(SignalDefinition.DeviceTimestamp): + if (_sourcePoint == null) Raise(nameof(DeviceTimestamp)); + break; + default: + return; + } + } + private void SourcePoint_PropertyChanged(object? sender, PropertyChangedEventArgs e) { switch (e.PropertyName) @@ -278,8 +316,11 @@ private void Raise([CallerMemberName] string? propertyName = null) public void Dispose() { + if (_sourceSignal != null) + _sourceSignal.PropertyChanged -= SourceSignal_PropertyChanged; if (_sourcePoint != null) _sourcePoint.PropertyChanged -= SourcePoint_PropertyChanged; + _sourceSignal = null; _sourcePoint = null; } } From 69e278bad90416218b2a59844f93ee587db12ec4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 15:13:37 +0700 Subject: [PATCH 04/33] refactor(fat): reconcile against canonical Explorer signal scope --- Services/NativeFatStateStore.cs | 89 ++++++++++++++++++++++++++------- 1 file changed, 72 insertions(+), 17 deletions(-) diff --git a/Services/NativeFatStateStore.cs b/Services/NativeFatStateStore.cs index 29a0ec81c..628f079f0 100644 --- a/Services/NativeFatStateStore.cs +++ b/Services/NativeFatStateStore.cs @@ -4,7 +4,7 @@ namespace ArIED61850Tester.Services; /// -/// Non-destructive, per-IED FAT persistence. The store never deletes unmatched signal +/// Non-destructive, per-IED FAT persistence. The store never deletes unmatched signal /// records during reconciliation; removed/changed engineering therefore remains visible /// as historical commissioning evidence instead of becoming a load error or data loss. /// @@ -57,7 +57,7 @@ public async Task LoadAsync( return candidate; } - // IED display names can change. Resolve by stable DeviceId before creating a new + // IED display names can change. Resolve by stable DeviceId before creating a new // state so a harmless rename cannot strand the operator's previous FAT evidence. foreach (var path in Directory.EnumerateFiles(_rootDirectory, "*.json", SearchOption.TopDirectoryOnly)) { @@ -128,6 +128,12 @@ await JsonSerializer.SerializeAsync(stream, state, JsonOptions, cancellationToke } } + /// + /// Reconcile saved FAT evidence against the same signal scope owned by IED Explorer. + /// Selected Explorer signals win; if no explicit selection exists, already-materialized + /// live points define the active scope; only an entirely fresh/offline device falls back + /// to all publishable process signals. + /// public static void Reconcile(NativeFatDeviceState state, Iec61850MonitorDevice device) { ArgumentNullException.ThrowIfNull(state); @@ -144,9 +150,9 @@ public static void Reconcile(NativeFatDeviceState state, Iec61850MonitorDevice d .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); var currentKeys = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var point in device.Points) + foreach (var signal in GetCurrentExplorerSignals(device)) { - var key = NativeFatIdentity.BuildKey(point); + var key = NativeFatIdentity.BuildKey(signal); if (string.IsNullOrWhiteSpace(key)) continue; @@ -156,10 +162,10 @@ public static void Reconcile(NativeFatDeviceState state, Iec61850MonitorDevice d saved = new NativeFatSignalState { Key = key, - SignalName = point.SignalName, - IecReference = point.IecReference, - FunctionalConstraint = point.FunctionalConstraint, - DataType = point.IecDataType, + SignalName = signal.Name, + IecReference = signal.ObjectReference, + FunctionalConstraint = signal.FunctionalConstraint, + DataType = signal.DataType, FirstSeenUtc = now, LastSeenUtc = now, Result = NativeFatResult.Untested, @@ -170,12 +176,12 @@ public static void Reconcile(NativeFatDeviceState state, Iec61850MonitorDevice d } else { - // Metadata follows the current engineering model while captures/history - // remain untouched. This is what makes signal display-name changes safe. - saved.SignalName = point.SignalName; - saved.IecReference = point.IecReference; - saved.FunctionalConstraint = point.FunctionalConstraint; - saved.DataType = point.IecDataType; + // Current engineering metadata is refreshed while captures/history are + // untouched. A display-name change is therefore a rename, not a new test. + saved.SignalName = signal.Name; + saved.IecReference = signal.ObjectReference; + saved.FunctionalConstraint = signal.FunctionalConstraint; + saved.DataType = signal.DataType; saved.LastSeenUtc = now; saved.IsHistorical = false; saved.History ??= new List(); @@ -184,6 +190,8 @@ public static void Reconcile(NativeFatDeviceState state, Iec61850MonitorDevice d } } + // Never delete. When a signal leaves the current Explorer scope or SCL, preserve it + // as historical evidence. Re-adding the same IEC identity automatically restores it. foreach (var saved in state.Signals) { if (!currentKeys.Contains(saved.Key)) @@ -200,20 +208,67 @@ public static IReadOnlyList BuildRows( ArgumentNullException.ThrowIfNull(state); ArgumentNullException.ThrowIfNull(device); - var current = device.Points + var signals = GetCurrentExplorerSignals(device) + .GroupBy(NativeFatIdentity.BuildKey, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + var points = device.Points .GroupBy(NativeFatIdentity.BuildKey, StringComparer.OrdinalIgnoreCase) .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); return state.Signals .Select(saved => new NativeFatSignalRow( saved, - current.TryGetValue(saved.Key, out var point) ? point : null)) + signals.TryGetValue(saved.Key, out var signal) ? signal : null, + points.TryGetValue(saved.Key, out var point) ? point : FindPointByReference(device, saved))) .OrderBy(row => row.IsHistorical) .ThenBy(row => row.SignalName, StringComparer.OrdinalIgnoreCase) .ThenBy(row => row.IecReference, StringComparer.OrdinalIgnoreCase) .ToArray(); } + public static IReadOnlyList GetCurrentExplorerSignals(Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(device); + + var publishable = device.Signals + .Where(signal => signal.CanPublishAsSignal) + .ToArray(); + if (publishable.Length == 0) + return Array.Empty(); + + var selected = publishable.Where(signal => signal.IsSelected).ToArray(); + if (selected.Length > 0) + return selected; + + if (device.Points.Count > 0) + { + var activeKeys = device.Points + .Select(NativeFatIdentity.BuildKey) + .Where(key => !string.IsNullOrWhiteSpace(key)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var materialized = publishable + .Where(signal => activeKeys.Contains(NativeFatIdentity.BuildKey(signal))) + .ToArray(); + if (materialized.Length > 0) + return materialized; + } + + return publishable; + } + + private static Iec61850MonitorPoint? FindPointByReference( + Iec61850MonitorDevice device, + NativeFatSignalState saved) + { + var reference = NativeFatIdentity.NormalizeReference(saved.IecReference); + if (string.IsNullOrWhiteSpace(reference)) + return null; + + return device.Points.FirstOrDefault(point => + NativeFatIdentity.NormalizeReference(point.IecReference) + .Equals(reference, StringComparison.OrdinalIgnoreCase)); + } + private async Task TryReadAsync(string path, CancellationToken cancellationToken) { if (!File.Exists(path)) @@ -237,7 +292,7 @@ public static IReadOnlyList BuildRows( } catch { - // Tolerant by design: a damaged old file must not block engineering. Leave + // Tolerant by design: a damaged old file must not block engineering. Leave // the original file untouched so it can still be recovered manually. return null; } From 2d5a962c933cd7ac1c1b41202cb5da2a95cc41ed Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 15:18:38 +0700 Subject: [PATCH 05/33] feat(fat): add native continuous FAT workspace tab --- MainWindow.NativeFatWorkspace.cs | 980 +++++++++++++++++++++++++++++++ 1 file changed, 980 insertions(+) create mode 100644 MainWindow.NativeFatWorkspace.cs diff --git a/MainWindow.NativeFatWorkspace.cs b/MainWindow.NativeFatWorkspace.cs new file mode 100644 index 000000000..3c28770ea --- /dev/null +++ b/MainWindow.NativeFatWorkspace.cs @@ -0,0 +1,980 @@ +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Animation; +using System.Windows.Threading; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +/// +/// Native continuous FAT workspace layered on the persistent P0/P1 Engineering shell. +/// +/// Authority boundary: +/// - IED Explorer owns SelectedDevice, signal engineering, live value and acquisition. +/// - the shared Command Dock remains the only command UI/runtime owner. +/// - this workspace owns only FAT captures, result/history and per-IED persistence. +/// +/// The legacy IoListTestingWindow remains untouched as a compatibility fallback while +/// report/evidence parity is migrated incrementally. +/// +public partial class MainWindow +{ + private const int NativeFatWorkspaceIndex = 6; + + private readonly NativeFatStateStore _nativeFatStore = new(); + private readonly ObservableCollection _nativeFatRows = new(); + private readonly Dictionary _nativeFatStateCache = new(StringComparer.OrdinalIgnoreCase); + private readonly SemaphoreSlim _nativeFatSaveGate = new(1, 1); + + private DispatcherTimer? _nativeFatInstallRetry; + private DispatcherTimer? _nativeFatReconcileTimer; + private DispatcherTimer? _nativeFatSaveTimer; + private CancellationTokenSource? _nativeFatLoadCts; + private TabItem? _nativeFatTab; + private Button? _nativeFatNavButton; + private DataGrid? _nativeFatGrid; + private DataGrid? _nativeFatPreviewGrid; + private ICollectionView? _nativeFatView; + private TextBox? _nativeFatSearchBox; + private CheckBox? _nativeFatShowHistoricalCheck; + private Border? _nativeFatPreviewPane; + private ColumnDefinition? _nativeFatPreviewGapColumn; + private ColumnDefinition? _nativeFatPreviewColumn; + private TextBlock? _nativeFatContextText; + private TextBlock? _nativeFatStatusText; + private TextBlock? _nativeFatPreviewDeviceText; + private TextBlock? _nativeFatPreviewSummaryText; + private TextBlock? _nativeFatPersistenceText; + private Iec61850MonitorDevice? _nativeFatObservedDevice; + private NativeFatDeviceState? _nativeFatCurrentState; + private bool _nativeFatInstalled; + private bool _nativeFatLoading; + private bool _nativeFatPreviewVisible; + + [ModuleInitializer] + internal static void RegisterNativeFatWorkspace() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(NativeFatWorkspace_MainWindowLoaded), + handledEventsToo: true); + } + + private static void NativeFatWorkspace_MainWindowLoaded(object sender, RoutedEventArgs e) + { + if (sender is not MainWindow window || window._nativeFatInstalled) + return; + + // P0 moves MainTabs/Explorer/Command Dock at ContextIdle. Install FAT after that + // re-parenting has settled so this stacked feature never races the stable P0 shell. + window.Dispatcher.BeginInvoke( + DispatcherPriority.ApplicationIdle, + new Action(window.TryInstallNativeFatWorkspace)); + } + + private void TryInstallNativeFatWorkspace() + { + if (_nativeFatInstalled || !IsLoaded) + return; + + if (_persistentWorkbench == null || MainTabs.Items.Count < 6) + { + _nativeFatInstallRetry ??= new DispatcherTimer(DispatcherPriority.ApplicationIdle) + { + Interval = TimeSpan.FromMilliseconds(140) + }; + _nativeFatInstallRetry.Tick -= NativeFatInstallRetry_Tick; + _nativeFatInstallRetry.Tick += NativeFatInstallRetry_Tick; + _nativeFatInstallRetry.Start(); + return; + } + + _nativeFatInstallRetry?.Stop(); + _nativeFatInstalled = true; + + _nativeFatTab = new TabItem + { + Header = "FAT", + Content = BuildNativeFatWorkspaceContent() + }; + MainTabs.Items.Add(_nativeFatTab); + + // The command dock is deliberately shared, not cloned. FAT is command-centric, + // therefore start with the same expanded behavior as Explorer/Event Log. + _persistentWorkbench.DockExpandedByWorkspace[NativeFatWorkspaceIndex] = true; + + InstallNativeFatNavigationButton(); + InstallNativeFatTimers(); + AttachNativeFatObservedDevice(SelectedDevice); + + PropertyChanged += NativeFat_MainWindowPropertyChanged; + MainTabs.SelectionChanged += NativeFat_MainTabsSelectionChanged; + SizeChanged += NativeFat_MainWindowSizeChanged; + Closed += NativeFat_MainWindowClosed; + + QueueNativeFatNavigationGeometry(); + UpdateNativeFatSummary(); + } + + private void NativeFatInstallRetry_Tick(object? sender, EventArgs e) + { + _nativeFatInstallRetry?.Stop(); + TryInstallNativeFatWorkspace(); + } + + private UIElement BuildNativeFatWorkspaceContent() + { + var root = new Grid { Margin = new Thickness(0) }; + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(8) }); + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(8) }); + root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + + var header = BuildNativeFatHeader(); + Grid.SetRow(header, 0); + root.Children.Add(header); + + var toolbar = BuildNativeFatToolbar(); + Grid.SetRow(toolbar, 2); + root.Children.Add(toolbar); + + var body = BuildNativeFatBody(); + Grid.SetRow(body, 4); + root.Children.Add(body); + return root; + } + + private Border BuildNativeFatHeader() + { + var border = new Border + { + Padding = new Thickness(14, 10, 14, 10), + CornerRadius = new CornerRadius(12), + Background = ResourceBrush("SurfaceElevated", Colors.White), + BorderBrush = ResourceBrush("Line", Color.FromRgb(0xDD, 0xE5, 0xF0)), + BorderThickness = new Thickness(1) + }; + + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var titleStack = new StackPanel(); + titleStack.Children.Add(new TextBlock + { + Text = "FAT · Continuous Testing", + FontSize = 14.5, + FontWeight = FontWeights.SemiBold, + Foreground = ResourceBrush("Ink", Color.FromRgb(0x20, 0x30, 0x4A)) + }); + titleStack.Children.Add(new TextBlock + { + Text = "Explorer signal authority · live values stay shared · captures autosave per IED · removed signals keep their history", + Margin = new Thickness(0, 3, 0, 0), + FontSize = 10.8, + Foreground = ResourceBrush("Muted", Color.FromRgb(0x66, 0x75, 0x8B)), + TextWrapping = TextWrapping.Wrap + }); + grid.Children.Add(titleStack); + + _nativeFatContextText = new TextBlock + { + Text = "IED · NONE", + FontSize = 10.5, + FontWeight = FontWeights.SemiBold, + Foreground = ResourceBrush("Accent", Color.FromRgb(0x25, 0x63, 0xEB)), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(14, 0, 0, 0) + }; + Grid.SetColumn(_nativeFatContextText, 1); + grid.Children.Add(_nativeFatContextText); + + border.Child = grid; + return border; + } + + private Border BuildNativeFatToolbar() + { + var border = new Border + { + Padding = new Thickness(9, 7, 9, 7), + CornerRadius = new CornerRadius(11), + Background = ResourceBrush("Surface", Colors.White), + BorderBrush = ResourceBrush("Line", Color.FromRgb(0xDD, 0xE5, 0xF0)), + BorderThickness = new Thickness(1) + }; + + var wrap = new WrapPanel + { + Orientation = Orientation.Horizontal, + VerticalAlignment = VerticalAlignment.Center + }; + + _nativeFatSearchBox = new TextBox + { + Width = 210, + Height = 30, + Margin = new Thickness(0, 0, 8, 0), + Padding = new Thickness(8, 4, 8, 4), + VerticalContentAlignment = VerticalAlignment.Center, + ToolTip = "Search Signal, IEC reference, type, value, result or history state" + }; + _nativeFatSearchBox.TextChanged += NativeFatSearchBox_TextChanged; + wrap.Children.Add(_nativeFatSearchBox); + + wrap.Children.Add(CreateNativeFatButton("Refresh", NativeFatRefresh_Click, "Reconcile the saved FAT state with the current Explorer signal scope.")); + wrap.Children.Add(CreateNativeFatButton("Capture V1", NativeFatCapture1_Click, "Capture the current Explorer live value into Value 1 for selected FAT rows.")); + wrap.Children.Add(CreateNativeFatButton("Capture V2", NativeFatCapture2_Click, "Capture the current Explorer live value into Value 2 for selected FAT rows.")); + wrap.Children.Add(CreateNativeFatButton("PASS", NativeFatPass_Click, "Mark selected FAT rows PASS; previous results remain in history.")); + wrap.Children.Add(CreateNativeFatButton("REVIEW", NativeFatReview_Click, "Mark selected FAT rows REVIEW; previous results remain in history.")); + wrap.Children.Add(CreateNativeFatButton("FAIL", NativeFatFail_Click, "Mark selected FAT rows FAIL; previous results remain in history.")); + wrap.Children.Add(CreateNativeFatButton("Reset current", NativeFatReset_Click, "Clear current captures/result while retaining the previous state in history.")); + wrap.Children.Add(CreateNativeFatButton("Report Preview", NativeFatReportPreview_Click, "Show or hide the native FAT report preview without opening another FAT runtime.")); + + _nativeFatShowHistoricalCheck = new CheckBox + { + Content = "Show historical", + IsChecked = false, + Margin = new Thickness(7, 6, 7, 0), + VerticalAlignment = VerticalAlignment.Center, + FontSize = 10.7, + Foreground = ResourceBrush("Muted", Color.FromRgb(0x66, 0x75, 0x8B)), + ToolTip = "Show signals removed from the current Explorer/SCL scope. Their previous FAT evidence is never deleted." + }; + _nativeFatShowHistoricalCheck.Checked += NativeFatShowHistorical_Changed; + _nativeFatShowHistoricalCheck.Unchecked += NativeFatShowHistorical_Changed; + wrap.Children.Add(_nativeFatShowHistoricalCheck); + + _nativeFatStatusText = new TextBlock + { + Text = "Select an IED to begin.", + Margin = new Thickness(9, 7, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + FontSize = 10.4, + Foreground = ResourceBrush("Muted", Color.FromRgb(0x66, 0x75, 0x8B)) + }; + wrap.Children.Add(_nativeFatStatusText); + + border.Child = wrap; + return border; + } + + private Grid BuildNativeFatBody() + { + var body = new Grid(); + body.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star), MinWidth = 0 }); + _nativeFatPreviewGapColumn = new ColumnDefinition { Width = new GridLength(0) }; + _nativeFatPreviewColumn = new ColumnDefinition { Width = new GridLength(0), MinWidth = 0 }; + body.ColumnDefinitions.Add(_nativeFatPreviewGapColumn); + body.ColumnDefinitions.Add(_nativeFatPreviewColumn); + + _nativeFatGrid = BuildNativeFatGrid(preview: false); + Grid.SetColumn(_nativeFatGrid, 0); + body.Children.Add(_nativeFatGrid); + + _nativeFatPreviewPane = BuildNativeFatPreviewPane(); + _nativeFatPreviewPane.Visibility = Visibility.Collapsed; + Grid.SetColumn(_nativeFatPreviewPane, 2); + body.Children.Add(_nativeFatPreviewPane); + return body; + } + + private DataGrid BuildNativeFatGrid(bool preview) + { + var grid = new DataGrid + { + AutoGenerateColumns = false, + CanUserAddRows = false, + CanUserDeleteRows = false, + CanUserReorderColumns = true, + CanUserResizeColumns = true, + HeadersVisibility = DataGridHeadersVisibility.Column, + IsReadOnly = true, + SelectionMode = preview ? DataGridSelectionMode.Single : DataGridSelectionMode.Extended, + SelectionUnit = DataGridSelectionUnit.FullRow, + GridLinesVisibility = DataGridGridLinesVisibility.Horizontal, + HorizontalGridLinesBrush = ResourceBrush("Line", Color.FromRgb(0xE2, 0xE8, 0xF0)), + BorderBrush = ResourceBrush("Line", Color.FromRgb(0xDD, 0xE5, 0xF0)), + BorderThickness = new Thickness(1), + RowHeight = preview ? 29 : 32, + ColumnHeaderHeight = 31, + Background = ResourceBrush("Surface", Colors.White), + AlternatingRowBackground = new SolidColorBrush(Color.FromRgb(0xFA, 0xFB, 0xFD)), + EnableRowVirtualization = true, + EnableColumnVirtualization = true + }; + + if (TryFindResource("DataGridHeaderCompact") is Style headerStyle) + grid.ColumnHeaderStyle = headerStyle; + if (TryFindResource("DataGridCellCompact") is Style cellStyle) + grid.CellStyle = cellStyle; + + var rowStyle = new Style(typeof(DataGridRow)); + rowStyle.Setters.Add(new Setter(Control.FontSizeProperty, preview ? 10.0 : 10.4)); + var historicalTrigger = new DataTrigger + { + Binding = new Binding(nameof(NativeFatSignalRow.IsHistorical)), + Value = true + }; + historicalTrigger.Setters.Add(new Setter(UIElement.OpacityProperty, 0.56)); + rowStyle.Triggers.Add(historicalTrigger); + grid.RowStyle = rowStyle; + + if (!preview) + { + grid.Columns.Add(TextColumn("SIGNAL", nameof(NativeFatSignalRow.SignalName), 1.25, minWidth: 130)); + grid.Columns.Add(TextColumn("IEC REFERENCE", nameof(NativeFatSignalRow.IecReference), 1.85, minWidth: 190)); + grid.Columns.Add(TextColumn("TYPE", nameof(NativeFatSignalRow.DataType), 0.72, minWidth: 72)); + grid.Columns.Add(TextColumn("LIVE VALUE", nameof(NativeFatSignalRow.LiveValue), 0.9, minWidth: 90)); + grid.Columns.Add(TextColumn("QUALITY", nameof(NativeFatSignalRow.Quality), 0.82, minWidth: 82)); + grid.Columns.Add(TextColumn("VALUE 1", nameof(NativeFatSignalRow.Value1Text), 0.78, minWidth: 78)); + grid.Columns.Add(TextColumn("VALUE 2", nameof(NativeFatSignalRow.Value2Text), 0.78, minWidth: 78)); + grid.Columns.Add(TextColumn("STATUS", nameof(NativeFatSignalRow.StatusText), 0.72, minWidth: 76)); + grid.Columns.Add(TextColumn("RESULT", nameof(NativeFatSignalRow.Result), 0.72, minWidth: 72)); + grid.Columns.Add(TextColumn("HISTORY", nameof(NativeFatSignalRow.HistoryText), 0.78, minWidth: 78)); + } + else + { + grid.Columns.Add(TextColumn("SIGNAL", nameof(NativeFatSignalRow.SignalName), 1.35, minWidth: 115)); + grid.Columns.Add(TextColumn("V1", nameof(NativeFatSignalRow.Value1Text), 0.65, minWidth: 62)); + grid.Columns.Add(TextColumn("V2", nameof(NativeFatSignalRow.Value2Text), 0.65, minWidth: 62)); + grid.Columns.Add(TextColumn("RESULT", nameof(NativeFatSignalRow.Result), 0.72, minWidth: 68)); + } + + return grid; + } + + private Border BuildNativeFatPreviewPane() + { + var border = new Border + { + Padding = new Thickness(11), + CornerRadius = new CornerRadius(12), + Background = ResourceBrush("SurfaceElevated", Colors.White), + BorderBrush = ResourceBrush("Line", Color.FromRgb(0xDD, 0xE5, 0xF0)), + BorderThickness = new Thickness(1) + }; + + var grid = new Grid(); + grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(8) }); + grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + + var title = new TextBlock + { + Text = "FAT Report Preview", + FontSize = 13, + FontWeight = FontWeights.SemiBold, + Foreground = ResourceBrush("Ink", Color.FromRgb(0x20, 0x30, 0x4A)) + }; + grid.Children.Add(title); + + _nativeFatPreviewDeviceText = new TextBlock + { + Text = "IED · NONE", + Margin = new Thickness(0, 4, 0, 0), + FontSize = 10.6, + FontWeight = FontWeights.SemiBold, + Foreground = ResourceBrush("Accent", Color.FromRgb(0x25, 0x63, 0xEB)) + }; + Grid.SetRow(_nativeFatPreviewDeviceText, 1); + grid.Children.Add(_nativeFatPreviewDeviceText); + + _nativeFatPreviewSummaryText = new TextBlock + { + Text = "No FAT state loaded.", + Margin = new Thickness(0, 4, 0, 0), + FontSize = 10.2, + Foreground = ResourceBrush("Muted", Color.FromRgb(0x66, 0x75, 0x8B)), + TextWrapping = TextWrapping.Wrap + }; + Grid.SetRow(_nativeFatPreviewSummaryText, 2); + grid.Children.Add(_nativeFatPreviewSummaryText); + + _nativeFatPreviewGrid = BuildNativeFatGrid(preview: true); + Grid.SetRow(_nativeFatPreviewGrid, 4); + grid.Children.Add(_nativeFatPreviewGrid); + + _nativeFatPersistenceText = new TextBlock + { + Text = "Per-IED JSON · non-destructive reconciliation", + Margin = new Thickness(0, 7, 0, 0), + FontSize = 9.5, + Foreground = ResourceBrush("Muted", Color.FromRgb(0x66, 0x75, 0x8B)), + TextWrapping = TextWrapping.Wrap + }; + Grid.SetRow(_nativeFatPersistenceText, 5); + grid.Children.Add(_nativeFatPersistenceText); + + border.Child = grid; + return border; + } + + private DataGridTextColumn TextColumn(string header, string path, double star, double minWidth) + => new() + { + Header = header, + Binding = new Binding(path) { Mode = BindingMode.OneWay }, + Width = new DataGridLength(star, DataGridLengthUnitType.Star), + MinWidth = minWidth + }; + + private Button CreateNativeFatButton(string text, RoutedEventHandler click, string toolTip) + { + var button = new Button + { + Content = text, + Margin = new Thickness(0, 0, 6, 0), + Padding = new Thickness(9, 5, 9, 5), + MinHeight = 29, + FontSize = 10.5, + ToolTip = toolTip + }; + if (TryFindResource("SoftButton") is Style style) + button.Style = style; + button.Click += click; + return button; + } + + private void InstallNativeFatNavigationButton() + { + if (_nativeFatNavButton != null) + return; + + while (WorkflowNavGrid.ColumnDefinitions.Count < 7) + WorkflowNavGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + Grid.SetColumnSpan(WorkflowPill, 7); + _nativeFatNavButton = new Button + { + Name = "NavNativeFatButton", + Content = "FAT", + Tag = NativeFatWorkspaceIndex, + MinWidth = 0, + ToolTip = "Continuous FAT testing using the selected IED and live Explorer signal authority" + }; + if (TryFindResource("SegmentedNavButton") is Style navStyle) + _nativeFatNavButton.Style = navStyle; + _nativeFatNavButton.Click += NativeFatNavButton_Click; + Grid.SetColumn(_nativeFatNavButton, 6); + WorkflowNavGrid.Children.Add(_nativeFatNavButton); + } + + private void InstallNativeFatTimers() + { + _nativeFatReconcileTimer = new DispatcherTimer(DispatcherPriority.Background) + { + Interval = TimeSpan.FromMilliseconds(320) + }; + _nativeFatReconcileTimer.Tick += NativeFatReconcileTimer_Tick; + + _nativeFatSaveTimer = new DispatcherTimer(DispatcherPriority.Background) + { + Interval = TimeSpan.FromMilliseconds(350) + }; + _nativeFatSaveTimer.Tick += NativeFatSaveTimer_Tick; + } + + private async void NativeFatNavButton_Click(object sender, RoutedEventArgs e) + { + if (MainTabs.Items.Count <= NativeFatWorkspaceIndex) + return; + + MainTabs.SelectedIndex = NativeFatWorkspaceIndex; + QueueNativeFatNavigationGeometry(); + await EnsureNativeFatLoadedAsync(forceReconcile: false); + } + + private void NativeFat_MainTabsSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (!ReferenceEquals(e.Source, MainTabs)) + return; + + QueueNativeFatNavigationGeometry(); + if (MainTabs.SelectedIndex == NativeFatWorkspaceIndex) + _ = EnsureNativeFatLoadedAsync(forceReconcile: false); + else + QueueNativeFatSave(); + } + + private void NativeFat_MainWindowSizeChanged(object sender, SizeChangedEventArgs e) + => QueueNativeFatNavigationGeometry(); + + private void QueueNativeFatNavigationGeometry() + { + if (!_nativeFatInstalled) + return; + Dispatcher.BeginInvoke( + DispatcherPriority.ApplicationIdle, + new Action(ApplyNativeFatNavigationGeometry)); + } + + private void ApplyNativeFatNavigationGeometry() + { + if (!_nativeFatInstalled || _nativeFatNavButton == null) + return; + + var availableWidth = ActualWidth > 0d ? ActualWidth : 1480d; + var wide = availableWidth >= 1700d; + var medium = availableWidth >= 1380d; + var shellWidth = wide ? 1085d : medium ? 995d : 805d; + + WorkflowNavShell.Width = shellWidth; + WorkflowNavShell.MinWidth = shellWidth; + WorkflowNavShell.Height = 60; + WorkflowNavShell.Padding = new Thickness(5, 6, 5, 6); + WorkflowNavGrid.ClipToBounds = false; + + while (WorkflowNavGrid.ColumnDefinitions.Count < 7) + WorkflowNavGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + foreach (var column in WorkflowNavGrid.ColumnDefinitions) + column.Width = new GridLength(1, GridUnitType.Star); + + var buttons = new[] + { + NavExplorerButton, + NavLiveButton, + NavEventsButton, + NavAlarmButton, + NavGooseButton, + NavDiagnosticsButton, + _nativeFatNavButton + }; + for (var index = 0; index < buttons.Length; index++) + { + var button = buttons[index]; + button.MinHeight = 40; + button.MinWidth = 0; + button.Margin = new Thickness(1); + button.Padding = wide ? new Thickness(9, 7, 9, 7) : new Thickness(5, 7, 5, 7); + button.HorizontalContentAlignment = HorizontalAlignment.Center; + button.VerticalContentAlignment = VerticalAlignment.Center; + button.Foreground = index == MainTabs.SelectedIndex + ? Brushes.White + : ResourceBrush("Muted", Color.FromRgb(0x5F, 0x6B, 0x7A)); + } + + Grid.SetColumnSpan(WorkflowPill, 7); + var contentWidth = Math.Max(0d, shellWidth - WorkflowNavShell.Padding.Left - WorkflowNavShell.Padding.Right); + var cellWidth = contentWidth / 7d; + WorkflowPill.Width = Math.Max(1d, cellWidth - 2d); + WorkflowPill.Height = 36; + + WorkflowPillTranslate.BeginAnimation(TranslateTransform.XProperty, null); + WorkflowPillTranslate.X = Math.Clamp(MainTabs.SelectedIndex, 0, NativeFatWorkspaceIndex) * cellWidth; + } + + private void NativeFat_MainWindowPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(SelectedDevice)) + return; + + QueueNativeFatSave(); + AttachNativeFatObservedDevice(SelectedDevice); + if (MainTabs.SelectedIndex == NativeFatWorkspaceIndex) + _ = EnsureNativeFatLoadedAsync(forceReconcile: true); + else + UpdateNativeFatSummary(); + } + + private void AttachNativeFatObservedDevice(Iec61850MonitorDevice? device) + { + if (ReferenceEquals(_nativeFatObservedDevice, device)) + return; + + if (_nativeFatObservedDevice != null) + { + _nativeFatObservedDevice.Signals.CollectionChanged -= NativeFatSignalCollectionChanged; + _nativeFatObservedDevice.Points.CollectionChanged -= NativeFatSignalCollectionChanged; + } + + _nativeFatObservedDevice = device; + if (_nativeFatObservedDevice != null) + { + _nativeFatObservedDevice.Signals.CollectionChanged += NativeFatSignalCollectionChanged; + _nativeFatObservedDevice.Points.CollectionChanged += NativeFatSignalCollectionChanged; + } + } + + private void NativeFatSignalCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (MainTabs.SelectedIndex != NativeFatWorkspaceIndex) + return; + _nativeFatReconcileTimer?.Stop(); + _nativeFatReconcileTimer?.Start(); + } + + private async void NativeFatReconcileTimer_Tick(object? sender, EventArgs e) + { + _nativeFatReconcileTimer?.Stop(); + await EnsureNativeFatLoadedAsync(forceReconcile: true); + } + + private async Task EnsureNativeFatLoadedAsync(bool forceReconcile) + { + if (!_nativeFatInstalled || MainTabs.SelectedIndex != NativeFatWorkspaceIndex) + return; + + var device = SelectedDevice; + if (device == null) + { + ClearNativeFatRows(); + _nativeFatCurrentState = null; + UpdateNativeFatSummary(); + return; + } + + var cacheKey = StableNativeFatDeviceKey(device); + if (!forceReconcile && _nativeFatCurrentState != null && + _nativeFatCurrentState.DeviceId.Equals(device.DeviceId, StringComparison.OrdinalIgnoreCase)) + { + UpdateNativeFatSummary(); + return; + } + + _nativeFatLoadCts?.Cancel(); + _nativeFatLoadCts?.Dispose(); + _nativeFatLoadCts = CancellationTokenSource.CreateLinkedTokenSource(_applicationCancellation.Token); + var token = _nativeFatLoadCts.Token; + _nativeFatLoading = true; + SetNativeFatStatus($"Loading FAT state for {device.Name}…"); + + try + { + NativeFatDeviceState state; + if (_nativeFatStateCache.TryGetValue(cacheKey, out var cached)) + { + state = cached; + } + else + { + state = await _nativeFatStore.LoadAsync(device, token); + token.ThrowIfCancellationRequested(); + _nativeFatStateCache[cacheKey] = state; + } + + // We are back on the WPF dispatcher here. Reconciliation enumerates Explorer + // ObservableCollections only on their owning UI thread. + token.ThrowIfCancellationRequested(); + if (!ReferenceEquals(device, SelectedDevice)) + return; + + NativeFatStateStore.Reconcile(state, device); + _nativeFatCurrentState = state; + RebuildNativeFatRows(state, device); + QueueNativeFatSave(); + SetNativeFatStatus($"FAT ready · {device.Name}"); + } + catch (OperationCanceledException) + { + // Fast IED switches are normal; stale loads are intentionally discarded. + } + catch (Exception ex) + { + AddLog("WARN", "Native FAT", $"FAT state load/reconcile failed: {ex.Message}"); + SetNativeFatStatus("FAT state could not be loaded. Explorer monitoring remains unaffected."); + } + finally + { + _nativeFatLoading = false; + UpdateNativeFatSummary(); + } + } + + private void RebuildNativeFatRows(NativeFatDeviceState state, Iec61850MonitorDevice device) + { + ClearNativeFatRows(); + foreach (var row in NativeFatStateStore.BuildRows(state, device)) + { + row.StateChanged += NativeFatRow_StateChanged; + _nativeFatRows.Add(row); + } + + _nativeFatView = CollectionViewSource.GetDefaultView(_nativeFatRows); + _nativeFatView.Filter = NativeFatViewFilter; + if (_nativeFatGrid != null) + _nativeFatGrid.ItemsSource = _nativeFatView; + if (_nativeFatPreviewGrid != null) + _nativeFatPreviewGrid.ItemsSource = _nativeFatRows; + UpdateNativeFatSummary(); + } + + private void ClearNativeFatRows() + { + foreach (var row in _nativeFatRows) + { + row.StateChanged -= NativeFatRow_StateChanged; + row.Dispose(); + } + _nativeFatRows.Clear(); + _nativeFatView = null; + if (_nativeFatGrid != null) + _nativeFatGrid.ItemsSource = null; + if (_nativeFatPreviewGrid != null) + _nativeFatPreviewGrid.ItemsSource = null; + } + + private bool NativeFatViewFilter(object item) + { + if (item is not NativeFatSignalRow row) + return false; + + if (row.IsHistorical && _nativeFatShowHistoricalCheck?.IsChecked != true) + return false; + + var query = _nativeFatSearchBox?.Text?.Trim(); + if (string.IsNullOrWhiteSpace(query)) + return true; + + return row.SignalName.Contains(query, StringComparison.OrdinalIgnoreCase) || + row.IecReference.Contains(query, StringComparison.OrdinalIgnoreCase) || + row.IecTelegram.Contains(query, StringComparison.OrdinalIgnoreCase) || + row.DataType.Contains(query, StringComparison.OrdinalIgnoreCase) || + row.LiveValue.Contains(query, StringComparison.OrdinalIgnoreCase) || + row.Value1Text.Contains(query, StringComparison.OrdinalIgnoreCase) || + row.Value2Text.Contains(query, StringComparison.OrdinalIgnoreCase) || + row.Result.Contains(query, StringComparison.OrdinalIgnoreCase) || + row.StatusText.Contains(query, StringComparison.OrdinalIgnoreCase); + } + + private void NativeFatSearchBox_TextChanged(object sender, TextChangedEventArgs e) + => _nativeFatView?.Refresh(); + + private void NativeFatShowHistorical_Changed(object sender, RoutedEventArgs e) + { + _nativeFatView?.Refresh(); + UpdateNativeFatSummary(); + } + + private void NativeFatRow_StateChanged(object? sender, EventArgs e) + { + QueueNativeFatSave(); + UpdateNativeFatSummary(); + _nativeFatPreviewGrid?.Items.Refresh(); + } + + private IReadOnlyList SelectedNativeFatRows() + { + if (_nativeFatGrid == null) + return Array.Empty(); + + var selected = _nativeFatGrid.SelectedItems.OfType().ToArray(); + if (selected.Length > 0) + return selected; + return _nativeFatGrid.CurrentItem is NativeFatSignalRow current + ? new[] { current } + : Array.Empty(); + } + + private void NativeFatRefresh_Click(object sender, RoutedEventArgs e) + => _ = EnsureNativeFatLoadedAsync(forceReconcile: true); + + private void NativeFatCapture1_Click(object sender, RoutedEventArgs e) + => CaptureNativeFatSelection(1); + + private void NativeFatCapture2_Click(object sender, RoutedEventArgs e) + => CaptureNativeFatSelection(2); + + private void CaptureNativeFatSelection(int slot) + { + var rows = SelectedNativeFatRows(); + if (rows.Count == 0) + { + SetNativeFatStatus("Select one or more FAT signals first."); + return; + } + + var captured = 0; + foreach (var row in rows) + { + if (row.CaptureValue(slot)) + captured++; + } + SetNativeFatStatus(captured == 0 + ? "No selected row has a current Explorer signal source. Historical rows cannot be captured." + : $"Captured Value {slot} for {captured} signal(s). Autosave queued."); + } + + private void NativeFatPass_Click(object sender, RoutedEventArgs e) + => SetNativeFatSelectionResult(NativeFatResult.Pass); + + private void NativeFatReview_Click(object sender, RoutedEventArgs e) + => SetNativeFatSelectionResult(NativeFatResult.Review); + + private void NativeFatFail_Click(object sender, RoutedEventArgs e) + => SetNativeFatSelectionResult(NativeFatResult.Fail); + + private void SetNativeFatSelectionResult(string result) + { + var rows = SelectedNativeFatRows(); + if (rows.Count == 0) + { + SetNativeFatStatus("Select one or more FAT signals first."); + return; + } + + foreach (var row in rows) + row.SetResult(result); + SetNativeFatStatus($"{rows.Count} signal(s) marked {result}. Previous state retained in history."); + } + + private void NativeFatReset_Click(object sender, RoutedEventArgs e) + { + var rows = SelectedNativeFatRows(); + if (rows.Count == 0) + { + SetNativeFatStatus("Select one or more FAT signals first."); + return; + } + + foreach (var row in rows) + row.ResetCurrentResult(); + SetNativeFatStatus($"Reset current FAT state for {rows.Count} signal(s); history was retained."); + } + + private void NativeFatReportPreview_Click(object sender, RoutedEventArgs e) + { + _nativeFatPreviewVisible = !_nativeFatPreviewVisible; + if (_nativeFatPreviewPane == null || _nativeFatPreviewGapColumn == null || _nativeFatPreviewColumn == null) + return; + + _nativeFatPreviewPane.Visibility = _nativeFatPreviewVisible ? Visibility.Visible : Visibility.Collapsed; + _nativeFatPreviewGapColumn.Width = new GridLength(_nativeFatPreviewVisible ? 10 : 0); + _nativeFatPreviewColumn.Width = _nativeFatPreviewVisible ? new GridLength(330) : new GridLength(0); + UpdateNativeFatSummary(); + } + + private void QueueNativeFatSave() + { + if (_nativeFatCurrentState == null || _nativeFatSaveTimer == null) + return; + _nativeFatSaveTimer.Stop(); + _nativeFatSaveTimer.Start(); + } + + private async void NativeFatSaveTimer_Tick(object? sender, EventArgs e) + { + _nativeFatSaveTimer?.Stop(); + if (_nativeFatCurrentState != null) + await SaveNativeFatStateAsync(_nativeFatCurrentState); + } + + private async Task SaveNativeFatStateAsync(NativeFatDeviceState state) + { + try + { + await _nativeFatSaveGate.WaitAsync(_applicationCancellation.Token); + try + { + await _nativeFatStore.SaveAsync(state, _applicationCancellation.Token); + } + finally + { + _nativeFatSaveGate.Release(); + } + + if (ReferenceEquals(state, _nativeFatCurrentState)) + { + SetNativeFatStatus($"Saved · {DateTime.Now:HH:mm:ss}"); + UpdateNativeFatSummary(); + } + } + catch (OperationCanceledException) + { + // Application shutdown or superseded state. + } + catch (Exception ex) + { + AddLog("WARN", "Native FAT", $"Autosave failed: {ex.Message}"); + if (ReferenceEquals(state, _nativeFatCurrentState)) + SetNativeFatStatus("Autosave failed; current in-memory FAT state is still available."); + } + } + + private void UpdateNativeFatSummary() + { + var device = SelectedDevice; + var deviceLabel = device == null ? "NONE" : device.Name; + if (_nativeFatContextText != null) + _nativeFatContextText.Text = $"IED · {deviceLabel}"; + if (_nativeFatPreviewDeviceText != null) + _nativeFatPreviewDeviceText.Text = $"IED · {deviceLabel}"; + + var current = _nativeFatRows.Count(row => !row.IsHistorical); + var historical = _nativeFatRows.Count(row => row.IsHistorical); + var pass = _nativeFatRows.Count(row => !row.IsHistorical && row.Result == NativeFatResult.Pass); + var review = _nativeFatRows.Count(row => !row.IsHistorical && row.Result == NativeFatResult.Review); + var fail = _nativeFatRows.Count(row => !row.IsHistorical && row.Result == NativeFatResult.Fail); + var untested = Math.Max(0, current - pass - review - fail); + + if (_nativeFatPreviewSummaryText != null) + { + _nativeFatPreviewSummaryText.Text = device == null + ? "Select an IED in the persistent Explorer." + : $"Current {current} · PASS {pass} · REVIEW {review} · FAIL {fail} · UNTESTED {untested} · historical {historical}"; + } + + if (_nativeFatPersistenceText != null) + { + _nativeFatPersistenceText.Text = _nativeFatCurrentState == null + ? "Per-IED JSON · non-destructive reconciliation" + : $"Resume file: {Path.GetFileName(_nativeFatCurrentState.StoragePath)}\nHistorical IEC identities remain stored when engineering changes."; + } + + if (!_nativeFatLoading && device != null && _nativeFatStatusText != null && + (_nativeFatStatusText.Text.StartsWith("Select", StringComparison.OrdinalIgnoreCase) || + _nativeFatStatusText.Text.StartsWith("Loading", StringComparison.OrdinalIgnoreCase))) + { + _nativeFatStatusText.Text = $"{current} current · {historical} historical · {untested} untested"; + } + } + + private void SetNativeFatStatus(string text) + { + if (_nativeFatStatusText != null) + _nativeFatStatusText.Text = text; + } + + private static string StableNativeFatDeviceKey(Iec61850MonitorDevice device) + => !string.IsNullOrWhiteSpace(device.DeviceId) ? device.DeviceId : device.Name; + + private Brush ResourceBrush(string key, Color fallback) + => TryFindResource(key) as Brush ?? new SolidColorBrush(fallback); + + private void NativeFat_MainWindowClosed(object? sender, EventArgs e) + { + _nativeFatInstallRetry?.Stop(); + _nativeFatReconcileTimer?.Stop(); + _nativeFatSaveTimer?.Stop(); + _nativeFatLoadCts?.Cancel(); + _nativeFatLoadCts?.Dispose(); + + PropertyChanged -= NativeFat_MainWindowPropertyChanged; + MainTabs.SelectionChanged -= NativeFat_MainTabsSelectionChanged; + SizeChanged -= NativeFat_MainWindowSizeChanged; + Closed -= NativeFat_MainWindowClosed; + + if (_nativeFatObservedDevice != null) + { + _nativeFatObservedDevice.Signals.CollectionChanged -= NativeFatSignalCollectionChanged; + _nativeFatObservedDevice.Points.CollectionChanged -= NativeFatSignalCollectionChanged; + } + + ClearNativeFatRows(); + _nativeFatSaveGate.Dispose(); + } +} From aecc453df240b4b2c98b664f4e8db98a219b1a0b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 15:39:21 +0700 Subject: [PATCH 06/33] Harden native FAT state identity and legacy migration --- Services/NativeFatStateStore.cs | 50 ++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/Services/NativeFatStateStore.cs b/Services/NativeFatStateStore.cs index 628f079f0..ce6b99988 100644 --- a/Services/NativeFatStateStore.cs +++ b/Services/NativeFatStateStore.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using ArIED61850Tester.Models; @@ -48,7 +50,10 @@ public async Task LoadAsync( ArgumentNullException.ThrowIfNull(device); Directory.CreateDirectory(_rootDirectory); - var preferredPath = GetPreferredPath(device.Name); + // New native FAT files include a short deterministic hash of stable DeviceId. + // Two relays may legitimately share the same display IEDName, so a name-only + // filename is not a safe multi-IED persistence identity. + var preferredPath = GetPreferredPath(device.Name, device.DeviceId); var candidate = await TryReadAsync(preferredPath, cancellationToken).ConfigureAwait(false); if (IsForDevice(candidate, device)) { @@ -57,18 +62,38 @@ public async Task LoadAsync( return candidate; } + // P2 preview builds used name-only filenames. Read them once for compatibility, + // but migrate the next save to the collision-safe preferred path. The old file is + // intentionally left untouched as recoverable commissioning evidence. + var legacyPath = GetLegacyPath(device.Name); + if (!legacyPath.Equals(preferredPath, StringComparison.OrdinalIgnoreCase)) + { + var legacy = await TryReadAsync(legacyPath, cancellationToken).ConfigureAwait(false); + if (IsForDevice(legacy, device)) + { + legacy!.StoragePath = preferredPath; + Normalize(legacy, device); + return legacy; + } + } + // IED display names can change. Resolve by stable DeviceId before creating a new // state so a harmless rename cannot strand the operator's previous FAT evidence. foreach (var path in Directory.EnumerateFiles(_rootDirectory, "*.json", SearchOption.TopDirectoryOnly)) { cancellationToken.ThrowIfCancellationRequested(); - if (path.Equals(preferredPath, StringComparison.OrdinalIgnoreCase)) + if (path.Equals(preferredPath, StringComparison.OrdinalIgnoreCase) || + path.Equals(legacyPath, StringComparison.OrdinalIgnoreCase)) + { continue; + } var probed = await TryReadAsync(path, cancellationToken).ConfigureAwait(false); if (!IsForDevice(probed, device)) continue; + // Preserve an already collision-safe file across display-name changes rather + // than creating a duplicate file every time the user renames an IED card. probed!.StoragePath = path; Normalize(probed, device); return probed; @@ -92,7 +117,7 @@ public async Task SaveAsync(NativeFatDeviceState state, CancellationToken cancel state.SchemaVersion = Math.Max(1, state.SchemaVersion); var path = string.IsNullOrWhiteSpace(state.StoragePath) - ? GetPreferredPath(state.IedName) + ? GetPreferredPath(state.IedName, state.DeviceId) : state.StoragePath; var tempPath = path + ".tmp-" + Guid.NewGuid().ToString("N"); @@ -326,9 +351,26 @@ private static void Normalize(NativeFatDeviceState state, Iec61850MonitorDevice } } - private string GetPreferredPath(string? iedName) + private string GetPreferredPath(string? iedName, string? deviceId) + { + var stem = SanitizeFileStem(iedName); + var deviceHash = StableDeviceHash(deviceId); + var fileStem = string.IsNullOrWhiteSpace(deviceHash) ? stem : $"{stem}__{deviceHash}"; + return Path.Combine(_rootDirectory, fileStem + ".json"); + } + + private string GetLegacyPath(string? iedName) => Path.Combine(_rootDirectory, SanitizeFileStem(iedName) + ".json"); + private static string StableDeviceHash(string? deviceId) + { + if (string.IsNullOrWhiteSpace(deviceId)) + return string.Empty; + + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(deviceId.Trim())); + return Convert.ToHexString(bytes.AsSpan(0, 6)); + } + private static string SanitizeFileStem(string? value) { var source = string.IsNullOrWhiteSpace(value) ? "IED" : value.Trim(); From 19505db1de7fe5c15c047c71456725bd24dff7bf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 15:39:40 +0700 Subject: [PATCH 07/33] Add immutable native FAT report snapshot models --- Models/NativeFatReportModels.cs | 63 +++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 Models/NativeFatReportModels.cs diff --git a/Models/NativeFatReportModels.cs b/Models/NativeFatReportModels.cs new file mode 100644 index 000000000..0433088e8 --- /dev/null +++ b/Models/NativeFatReportModels.cs @@ -0,0 +1,63 @@ +namespace ArIED61850Tester.Models; + +/// +/// Immutable report-time projection of one native FAT device. The snapshot deliberately +/// copies persisted evidence instead of binding a report directly to live Explorer rows, +/// so preview/export content cannot drift while acquisition continues in the background. +/// +public sealed record NativeFatReportSnapshot( + string DeviceId, + string IedName, + string IpAddress, + int Port, + DateTimeOffset GeneratedUtc, + IReadOnlyList Rows) +{ + public int CurrentCount => Rows.Count(row => !row.IsHistorical); + public int HistoricalCount => Rows.Count(row => row.IsHistorical); + public int PassCount => Rows.Count(row => !row.IsHistorical && row.Result == NativeFatResult.Pass); + public int ReviewCount => Rows.Count(row => !row.IsHistorical && row.Result == NativeFatResult.Review); + public int FailCount => Rows.Count(row => !row.IsHistorical && row.Result == NativeFatResult.Fail); + public int UntestedCount => Math.Max(0, CurrentCount - PassCount - ReviewCount - FailCount); + + public string SummaryText => + $"Current {CurrentCount} · PASS {PassCount} · REVIEW {ReviewCount} · FAIL {FailCount} · UNTESTED {UntestedCount} · historical {HistoricalCount}"; +} + +public sealed record NativeFatReportRow( + string SignalName, + string IecReference, + string FunctionalConstraint, + string DataType, + bool IsHistorical, + string Result, + int HistoryCount, + string Value1Text, + string Value1Quality, + string Value1DeviceTimestamp, + string Value1SourceMode, + DateTimeOffset? Value1CapturedUtc, + string Value2Text, + string Value2Quality, + string Value2DeviceTimestamp, + string Value2SourceMode, + DateTimeOffset? Value2CapturedUtc) +{ + public string ScopeText => IsHistorical ? "HISTORICAL" : "CURRENT"; + public string HistoryText => HistoryCount == 0 ? "—" : $"{HistoryCount} record{(HistoryCount == 1 ? string.Empty : "s")}"; + + public string Value1CapturedText => Value1CapturedUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss.fff") ?? "—"; + public string Value2CapturedText => Value2CapturedUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss.fff") ?? "—"; + + public string EvidenceSummaryText => + $"{IecReference} [{FunctionalConstraint}] · {DataType}\n" + + $"V1: {Value1Text} · q={Value1Quality} · IED={NormalizeTimestamp(Value1DeviceTimestamp)} · capture={Value1CapturedText} · {NormalizeSource(Value1SourceMode)}\n" + + $"V2: {Value2Text} · q={Value2Quality} · IED={NormalizeTimestamp(Value2DeviceTimestamp)} · capture={Value2CapturedText} · {NormalizeSource(Value2SourceMode)}\n" + + $"{ScopeText} · {Result} · history {HistoryText}"; + + private static string NormalizeTimestamp(string value) + => string.IsNullOrWhiteSpace(value) ? "—" : value.Trim(); + + private static string NormalizeSource(string value) + => string.IsNullOrWhiteSpace(value) ? "Unknown" : value.Trim(); +} From 2c84084f1c0fcce1be3442e5759382f186c7d30a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 15:39:53 +0700 Subject: [PATCH 08/33] Build deterministic native FAT report snapshots --- Services/NativeFatReportSnapshotBuilder.cs | 63 ++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 Services/NativeFatReportSnapshotBuilder.cs diff --git a/Services/NativeFatReportSnapshotBuilder.cs b/Services/NativeFatReportSnapshotBuilder.cs new file mode 100644 index 000000000..38523be5c --- /dev/null +++ b/Services/NativeFatReportSnapshotBuilder.cs @@ -0,0 +1,63 @@ +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +/// +/// Converts the persisted native FAT state into an immutable report snapshot. This is a +/// presentation/evidence boundary only; it never starts acquisition, changes command +/// state, or mutates the persisted FAT result. +/// +public static class NativeFatReportSnapshotBuilder +{ + public static NativeFatReportSnapshot Build( + Iec61850MonitorDevice device, + NativeFatDeviceState state, + DateTimeOffset? generatedUtc = null) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(state); + + var rows = (state.Signals ?? new List()) + .Select(BuildRow) + .OrderBy(row => row.IsHistorical) + .ThenBy(row => row.SignalName, StringComparer.OrdinalIgnoreCase) + .ThenBy(row => row.IecReference, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return new NativeFatReportSnapshot( + device.DeviceId, + device.Name, + device.IpAddress, + device.Port, + generatedUtc ?? DateTimeOffset.UtcNow, + rows); + } + + private static NativeFatReportRow BuildRow(NativeFatSignalState state) + { + var value1 = state.Value1; + var value2 = state.Value2; + var result = string.IsNullOrWhiteSpace(state.Result) + ? NativeFatResult.Untested + : state.Result.Trim().ToUpperInvariant(); + + return new NativeFatReportRow( + SignalName: state.SignalName, + IecReference: state.IecReference, + FunctionalConstraint: state.FunctionalConstraint, + DataType: state.DataType, + IsHistorical: state.IsHistorical, + Result: result, + HistoryCount: state.History?.Count ?? 0, + Value1Text: value1?.Value ?? "—", + Value1Quality: value1?.Quality ?? "—", + Value1DeviceTimestamp: value1?.DeviceTimestamp ?? "—", + Value1SourceMode: value1?.SourceMode ?? "—", + Value1CapturedUtc: value1?.CapturedUtc, + Value2Text: value2?.Value ?? "—", + Value2Quality: value2?.Quality ?? "—", + Value2DeviceTimestamp: value2?.DeviceTimestamp ?? "—", + Value2SourceMode: value2?.SourceMode ?? "—", + Value2CapturedUtc: value2?.CapturedUtc); + } +} From e365da27ee13b630d72db9683b68a321e1434ac6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 15:40:43 +0700 Subject: [PATCH 09/33] Cover native FAT continuity and report snapshot regressions --- .../NativeFatStateStoreRegressionTests.cs | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 tests/ARSAS.Tests/NativeFatStateStoreRegressionTests.cs diff --git a/tests/ARSAS.Tests/NativeFatStateStoreRegressionTests.cs b/tests/ARSAS.Tests/NativeFatStateStoreRegressionTests.cs new file mode 100644 index 000000000..30c45d717 --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatStateStoreRegressionTests.cs @@ -0,0 +1,243 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class NativeFatStateStoreRegressionTests +{ + [Fact] + public void Reconcile_AddRenameRemoveReadd_PreservesExistingEvidence() + { + var device = CreateDevice("device-a", "Relay A"); + var original = CreateSignal("Trip original", "IED1LD0/GGIO1.Ind1.stVal"); + device.Signals.Add(original); + + var state = new NativeFatDeviceState(); + NativeFatStateStore.Reconcile(state, device); + + var saved = Assert.Single(state.Signals); + Assert.Equal(NativeFatResult.Untested, saved.Result); + Assert.False(saved.IsHistorical); + + using (var row = Assert.Single(NativeFatStateStore.BuildRows(state, device))) + { + original.Value = "True"; + original.Quality = "Good"; + original.DeviceTimestamp = "2026-09-09 08:15:01.125"; + Assert.True(row.CaptureValue(1)); + row.SetResult(NativeFatResult.Pass); + } + + original.Name = "Trip renamed"; + NativeFatStateStore.Reconcile(state, device); + saved = Assert.Single(state.Signals); + Assert.Equal("Trip renamed", saved.SignalName); + Assert.Equal(NativeFatResult.Pass, saved.Result); + Assert.Equal("True", saved.Value1?.Value); + Assert.False(saved.IsHistorical); + + device.Signals.Clear(); + NativeFatStateStore.Reconcile(state, device); + saved = Assert.Single(state.Signals); + Assert.True(saved.IsHistorical); + Assert.Equal(NativeFatResult.Pass, saved.Result); + Assert.Equal("True", saved.Value1?.Value); + + // Same IEC identity, different case and '$' separator: this must restore the old + // FAT row rather than creating a second UNTESTED row. + device.Signals.Add(CreateSignal("Trip re-added", "ied1ld0/GGIO1$Ind1$stVal")); + NativeFatStateStore.Reconcile(state, device); + saved = Assert.Single(state.Signals); + Assert.False(saved.IsHistorical); + Assert.Equal("Trip re-added", saved.SignalName); + Assert.Equal(NativeFatResult.Pass, saved.Result); + Assert.Equal("True", saved.Value1?.Value); + } + + [Fact] + public async Task SaveLoad_RestartAndIedRename_ResumeByStableDeviceId() + { + using var temp = new TemporaryDirectory(); + var device = CreateDevice("stable-device-01", "Relay Before Rename"); + var signal = CreateSignal("Breaker status", "IED1LD0/GGIO1.Ind2.stVal"); + device.Signals.Add(signal); + + var store = new NativeFatStateStore(temp.Path); + var state = await store.LoadAndReconcileAsync(device); + using (var row = Assert.Single(NativeFatStateStore.BuildRows(state, device))) + { + signal.Value = "False"; + signal.Quality = "Good"; + Assert.True(row.CaptureValue(1)); + row.SetResult(NativeFatResult.Review); + } + await store.SaveAsync(state); + var originalPath = state.StoragePath; + + device.Name = "Relay After Rename"; + var restartedStore = new NativeFatStateStore(temp.Path); + var resumed = await restartedStore.LoadAndReconcileAsync(device); + + var resumedSignal = Assert.Single(resumed.Signals); + Assert.Equal("Relay After Rename", resumed.IedName); + Assert.Equal(NativeFatResult.Review, resumedSignal.Result); + Assert.Equal("False", resumedSignal.Value1?.Value); + Assert.Equal(originalPath, resumed.StoragePath); + Assert.Single(Directory.GetFiles(temp.Path, "*.json")); + } + + [Fact] + public async Task SameDisplayNameDifferentDeviceIds_NeverShareOneStateFile() + { + using var temp = new TemporaryDirectory(); + var store = new NativeFatStateStore(temp.Path); + + var first = CreateDevice("device-one", "Duplicate IED Name"); + first.Signals.Add(CreateSignal("DI 1", "IED1LD0/GGIO1.Ind1.stVal")); + var firstState = await store.LoadAndReconcileAsync(first); + firstState.Signals.Single().Result = NativeFatResult.Pass; + await store.SaveAsync(firstState); + + var second = CreateDevice("device-two", "Duplicate IED Name"); + second.Signals.Add(CreateSignal("DI 1", "IED1LD0/GGIO1.Ind1.stVal")); + var secondState = await store.LoadAndReconcileAsync(second); + secondState.Signals.Single().Result = NativeFatResult.Fail; + await store.SaveAsync(secondState); + + Assert.NotEqual(firstState.StoragePath, secondState.StoragePath); + Assert.Equal(2, Directory.GetFiles(temp.Path, "*.json").Length); + + var restartedStore = new NativeFatStateStore(temp.Path); + var firstReloaded = await restartedStore.LoadAndReconcileAsync(first); + var secondReloaded = await restartedStore.LoadAndReconcileAsync(second); + Assert.Equal(NativeFatResult.Pass, Assert.Single(firstReloaded.Signals).Result); + Assert.Equal(NativeFatResult.Fail, Assert.Single(secondReloaded.Signals).Result); + } + + [Fact] + public void ReportSnapshot_FreezesEvidenceAndSummaryAtBuildTime() + { + var device = CreateDevice("device-report", "Report IED"); + var state = new NativeFatDeviceState + { + DeviceId = device.DeviceId, + IedName = device.Name, + Signals = + { + new NativeFatSignalState + { + Key = "IED1LD0/GGIO1.IND1.STVAL|ST", + SignalName = "Current DI", + IecReference = "IED1LD0/GGIO1.Ind1.stVal", + FunctionalConstraint = "ST", + DataType = "Boolean", + Result = NativeFatResult.Pass, + Value1 = new NativeFatCapture + { + Value = "False", + Quality = "Good", + DeviceTimestamp = "2026-09-09 08:00:00.001", + SourceMode = "BRCB", + CapturedUtc = new DateTimeOffset(2026, 9, 9, 8, 0, 1, TimeSpan.Zero) + }, + Value2 = new NativeFatCapture + { + Value = "True", + Quality = "Good", + DeviceTimestamp = "2026-09-09 08:00:05.002", + SourceMode = "BRCB", + CapturedUtc = new DateTimeOffset(2026, 9, 9, 8, 0, 6, TimeSpan.Zero) + }, + History = { new NativeFatHistoryEntry { Action = "Result PASS", Result = NativeFatResult.Pass } } + }, + new NativeFatSignalState + { + Key = "IED1LD0/GGIO1.IND9.STVAL|ST", + SignalName = "Removed DI", + IecReference = "IED1LD0/GGIO1.Ind9.stVal", + FunctionalConstraint = "ST", + DataType = "Boolean", + Result = NativeFatResult.Fail, + IsHistorical = true + } + } + }; + + var generated = new DateTimeOffset(2026, 9, 9, 8, 30, 0, TimeSpan.Zero); + var snapshot = NativeFatReportSnapshotBuilder.Build(device, state, generated); + + Assert.Equal(generated, snapshot.GeneratedUtc); + Assert.Equal(1, snapshot.CurrentCount); + Assert.Equal(1, snapshot.HistoricalCount); + Assert.Equal(1, snapshot.PassCount); + Assert.Equal(0, snapshot.FailCount); + Assert.Equal(0, snapshot.UntestedCount); + + var current = snapshot.Rows.Single(row => !row.IsHistorical); + Assert.Equal("False", current.Value1Text); + Assert.Equal("True", current.Value2Text); + Assert.Equal("Good", current.Value1Quality); + Assert.Equal("BRCB", current.Value2SourceMode); + Assert.Contains("history 1 record", current.EvidenceSummaryText, StringComparison.OrdinalIgnoreCase); + + // A report snapshot is immutable evidence. Later live/persisted mutation must not + // retroactively change what this preview/export build represented. + state.Signals[0].Value1!.Value = "CHANGED AFTER SNAPSHOT"; + state.Signals[0].Result = NativeFatResult.Review; + Assert.Equal("False", current.Value1Text); + Assert.Equal(NativeFatResult.Pass, current.Result); + } + + private static Iec61850MonitorDevice CreateDevice(string deviceId, string name) + => new() + { + DeviceId = deviceId, + Name = name, + IpAddress = "192.0.2.10", + Port = 102 + }; + + private static SignalDefinition CreateSignal(string name, string reference) + { + var signal = new SignalDefinition + { + Name = name, + ObjectReference = reference, + FunctionalConstraint = "ST", + DataType = "Boolean", + Category = "Status", + IsSelected = true, + ProbeStatus = "Readable" + }; + Assert.True(signal.CanPublishAsSignal, $"Test signal '{reference}' must be publishable by Explorer policy."); + return signal; + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + "ARSAS.Tests", + "NativeFat", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + try + { + if (Directory.Exists(Path)) + Directory.Delete(Path, recursive: true); + } + catch + { + // Test cleanup must not hide the actual assertion result. + } + } + } +} From 88c7cbd4ed7ef0e9482eeecca09f3150153d3a76 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 15:42:45 +0700 Subject: [PATCH 10/33] Drive native FAT report preview from immutable evidence snapshots --- MainWindow.NativeFatReportPreview.cs | 279 +++++++++++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 MainWindow.NativeFatReportPreview.cs diff --git a/MainWindow.NativeFatReportPreview.cs b/MainWindow.NativeFatReportPreview.cs new file mode 100644 index 000000000..bda0a5a72 --- /dev/null +++ b/MainWindow.NativeFatReportPreview.cs @@ -0,0 +1,279 @@ +using System.Collections.Specialized; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Media; +using System.Windows.Threading; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +/// +/// P2 report-preview hardening for the native FAT workspace. The preview consumes an +/// immutable evidence snapshot instead of the live row objects, while the main FAT grid +/// remains directly bound to Explorer/runtime values for testing. +/// +public partial class MainWindow +{ + private DispatcherTimer? _nativeFatReportPreviewInstallRetry; + private TextBlock? _nativeFatPreviewEvidenceText; + private NativeFatReportSnapshot? _nativeFatReportSnapshot; + private readonly HashSet _nativeFatReportObservedRows = new(); + private bool _nativeFatReportPreviewEnhanced; + private bool _nativeFatReportRefreshQueued; + + [ModuleInitializer] + internal static void RegisterNativeFatReportPreviewEnhancement() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(NativeFatReportPreview_MainWindowLoaded), + handledEventsToo: true); + } + + private static void NativeFatReportPreview_MainWindowLoaded(object sender, RoutedEventArgs e) + { + if (sender is not MainWindow window || window._nativeFatReportPreviewEnhanced) + return; + + window.Dispatcher.BeginInvoke( + DispatcherPriority.ApplicationIdle, + new Action(window.TryInstallNativeFatReportPreviewEnhancement)); + } + + private void TryInstallNativeFatReportPreviewEnhancement() + { + if (_nativeFatReportPreviewEnhanced || !IsLoaded) + return; + + if (!_nativeFatInstalled || _nativeFatPreviewPane?.Child is not Grid previewRoot || + _nativeFatPreviewGrid == null || _nativeFatPreviewSummaryText == null) + { + _nativeFatReportPreviewInstallRetry ??= new DispatcherTimer(DispatcherPriority.ApplicationIdle) + { + Interval = TimeSpan.FromMilliseconds(180) + }; + _nativeFatReportPreviewInstallRetry.Tick -= NativeFatReportPreviewInstallRetry_Tick; + _nativeFatReportPreviewInstallRetry.Tick += NativeFatReportPreviewInstallRetry_Tick; + _nativeFatReportPreviewInstallRetry.Start(); + return; + } + + _nativeFatReportPreviewInstallRetry?.Stop(); + _nativeFatReportPreviewEnhanced = true; + + // The initial P2 pane already has title/device/summary/grid/persistence rows. Add + // one compact evidence inspector between the grid and persistence note. + previewRoot.RowDefinitions.Insert(5, new RowDefinition { Height = GridLength.Auto }); + if (_nativeFatPersistenceText != null) + Grid.SetRow(_nativeFatPersistenceText, 6); + + _nativeFatPreviewEvidenceText = new TextBlock + { + Text = "Select a report row to inspect captured evidence.", + Margin = new Thickness(0, 8, 0, 0), + Padding = new Thickness(8, 7, 8, 7), + FontSize = 9.4, + Foreground = ResourceBrush("Muted", Color.FromRgb(0x66, 0x75, 0x8B)), + Background = new SolidColorBrush(Color.FromRgb(0xF7, 0xF9, 0xFC)), + TextWrapping = TextWrapping.Wrap + }; + Grid.SetRow(_nativeFatPreviewEvidenceText, 5); + previewRoot.Children.Add(_nativeFatPreviewEvidenceText); + + // Existing preview columns deliberately use property names also exposed by the + // snapshot row. Add the canonical IEC identity so report scope can be audited. + if (_nativeFatPreviewGrid.Columns.All(column => !Equals(column.Header, "IEC"))) + { + _nativeFatPreviewGrid.Columns.Insert(1, new DataGridTextColumn + { + Header = "IEC", + Binding = new Binding(nameof(NativeFatReportRow.IecReference)) { Mode = BindingMode.OneWay }, + Width = new DataGridLength(1.15, DataGridLengthUnitType.Star), + MinWidth = 105 + }); + } + + if (_nativeFatPreviewGrid.Columns.Count >= 5) + { + _nativeFatPreviewGrid.Columns[0].Width = new DataGridLength(1.15, DataGridLengthUnitType.Star); + _nativeFatPreviewGrid.Columns[2].Width = new DataGridLength(0.58, DataGridLengthUnitType.Star); + _nativeFatPreviewGrid.Columns[3].Width = new DataGridLength(0.58, DataGridLengthUnitType.Star); + _nativeFatPreviewGrid.Columns[4].Width = new DataGridLength(0.62, DataGridLengthUnitType.Star); + } + + _nativeFatPreviewGrid.SelectionChanged += NativeFatPreviewGrid_SelectionChanged; + _nativeFatPreviewPane!.IsVisibleChanged += NativeFatPreviewPane_IsVisibleChanged; + _nativeFatRows.CollectionChanged += NativeFatReportRows_CollectionChanged; + if (_nativeFatShowHistoricalCheck != null) + { + _nativeFatShowHistoricalCheck.Checked += NativeFatReportScope_Changed; + _nativeFatShowHistoricalCheck.Unchecked += NativeFatReportScope_Changed; + } + + SyncNativeFatReportRowSubscriptions(); + Closed += NativeFatReportPreview_MainWindowClosed; + QueueNativeFatReportPreviewRefresh(); + } + + private void NativeFatReportPreviewInstallRetry_Tick(object? sender, EventArgs e) + { + _nativeFatReportPreviewInstallRetry?.Stop(); + TryInstallNativeFatReportPreviewEnhancement(); + } + + private void NativeFatPreviewPane_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e) + { + if (_nativeFatPreviewPane?.IsVisible == true && _nativeFatPreviewColumn != null) + { + // 330 px was enough for a four-column mock preview but not for auditable IEC + // identity + evidence. Keep it compact while making the report pane useful. + _nativeFatPreviewColumn.Width = new GridLength(430); + QueueNativeFatReportPreviewRefresh(); + } + } + + private void NativeFatReportRows_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + SyncNativeFatReportRowSubscriptions(); + QueueNativeFatReportPreviewRefresh(); + } + + private void SyncNativeFatReportRowSubscriptions() + { + var current = _nativeFatRows.ToHashSet(); + foreach (var row in _nativeFatReportObservedRows.Where(row => !current.Contains(row)).ToArray()) + { + row.PropertyChanged -= NativeFatReportRow_PropertyChanged; + _nativeFatReportObservedRows.Remove(row); + } + + foreach (var row in current) + { + if (!_nativeFatReportObservedRows.Add(row)) + continue; + row.PropertyChanged += NativeFatReportRow_PropertyChanged; + } + } + + private void NativeFatReportRow_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(NativeFatSignalRow.Value1Text) or + nameof(NativeFatSignalRow.Value2Text) or + nameof(NativeFatSignalRow.Result) or + nameof(NativeFatSignalRow.HistoryText) or + nameof(NativeFatSignalRow.IsHistorical)) + { + QueueNativeFatReportPreviewRefresh(); + } + } + + private void NativeFatReportScope_Changed(object sender, RoutedEventArgs e) + => QueueNativeFatReportPreviewRefresh(); + + private void QueueNativeFatReportPreviewRefresh() + { + if (!_nativeFatReportPreviewEnhanced || _nativeFatPreviewPane?.IsVisible != true || _nativeFatReportRefreshQueued) + return; + + _nativeFatReportRefreshQueued = true; + Dispatcher.BeginInvoke( + DispatcherPriority.ContextIdle, + new Action(() => + { + _nativeFatReportRefreshQueued = false; + RefreshNativeFatReportPreviewSnapshot(); + })); + } + + private void RefreshNativeFatReportPreviewSnapshot() + { + if (_nativeFatPreviewPane?.IsVisible != true || _nativeFatPreviewGrid == null) + return; + + var device = SelectedDevice; + var state = _nativeFatCurrentState; + if (device == null || state == null || + !state.DeviceId.Equals(device.DeviceId, StringComparison.OrdinalIgnoreCase)) + { + _nativeFatReportSnapshot = null; + _nativeFatPreviewGrid.ItemsSource = null; + if (_nativeFatPreviewSummaryText != null) + _nativeFatPreviewSummaryText.Text = "Select an IED with a loaded FAT state."; + if (_nativeFatPreviewEvidenceText != null) + _nativeFatPreviewEvidenceText.Text = "No report evidence is loaded."; + return; + } + + var previousReference = (_nativeFatPreviewGrid.SelectedItem as NativeFatReportRow)?.IecReference; + _nativeFatReportSnapshot = NativeFatReportSnapshotBuilder.Build(device, state); + var showHistorical = _nativeFatShowHistoricalCheck?.IsChecked == true; + var visibleRows = _nativeFatReportSnapshot.Rows + .Where(row => showHistorical || !row.IsHistorical) + .ToArray(); + _nativeFatPreviewGrid.ItemsSource = visibleRows; + + if (_nativeFatPreviewDeviceText != null) + _nativeFatPreviewDeviceText.Text = $"IED · {_nativeFatReportSnapshot.IedName} · {_nativeFatReportSnapshot.IpAddress}:{_nativeFatReportSnapshot.Port}"; + if (_nativeFatPreviewSummaryText != null) + { + var scopeSuffix = !showHistorical && _nativeFatReportSnapshot.HistoricalCount > 0 + ? " · historical hidden" + : string.Empty; + _nativeFatPreviewSummaryText.Text = _nativeFatReportSnapshot.SummaryText + scopeSuffix; + } + + NativeFatReportRow? selection = null; + if (!string.IsNullOrWhiteSpace(previousReference)) + { + selection = visibleRows.FirstOrDefault(row => + row.IecReference.Equals(previousReference, StringComparison.OrdinalIgnoreCase)); + } + selection ??= visibleRows.FirstOrDefault(); + if (selection != null) + { + _nativeFatPreviewGrid.SelectedItem = selection; + _nativeFatPreviewGrid.ScrollIntoView(selection); + } + else + { + UpdateNativeFatPreviewEvidence(null); + } + } + + private void NativeFatPreviewGrid_SelectionChanged(object sender, SelectionChangedEventArgs e) + => UpdateNativeFatPreviewEvidence(_nativeFatPreviewGrid?.SelectedItem as NativeFatReportRow); + + private void UpdateNativeFatPreviewEvidence(NativeFatReportRow? row) + { + if (_nativeFatPreviewEvidenceText == null) + return; + + _nativeFatPreviewEvidenceText.Text = row?.EvidenceSummaryText ?? + "Select a report row to inspect captured evidence."; + } + + private void NativeFatReportPreview_MainWindowClosed(object? sender, EventArgs e) + { + _nativeFatReportPreviewInstallRetry?.Stop(); + if (_nativeFatPreviewGrid != null) + _nativeFatPreviewGrid.SelectionChanged -= NativeFatPreviewGrid_SelectionChanged; + if (_nativeFatPreviewPane != null) + _nativeFatPreviewPane.IsVisibleChanged -= NativeFatPreviewPane_IsVisibleChanged; + _nativeFatRows.CollectionChanged -= NativeFatReportRows_CollectionChanged; + if (_nativeFatShowHistoricalCheck != null) + { + _nativeFatShowHistoricalCheck.Checked -= NativeFatReportScope_Changed; + _nativeFatShowHistoricalCheck.Unchecked -= NativeFatReportScope_Changed; + } + + foreach (var row in _nativeFatReportObservedRows) + row.PropertyChanged -= NativeFatReportRow_PropertyChanged; + _nativeFatReportObservedRows.Clear(); + Closed -= NativeFatReportPreview_MainWindowClosed; + } +} From 3bfc3ea61bc4e47b3c8e5122b920c474a426e89a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 15:44:54 +0700 Subject: [PATCH 11/33] Preserve unreadable native FAT evidence files --- Services/NativeFatStateStore.cs | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/Services/NativeFatStateStore.cs b/Services/NativeFatStateStore.cs index ce6b99988..be77a0def 100644 --- a/Services/NativeFatStateStore.cs +++ b/Services/NativeFatStateStore.cs @@ -61,6 +61,7 @@ public async Task LoadAsync( Normalize(candidate, device); return candidate; } + var preferredPathOccupied = File.Exists(preferredPath); // P2 preview builds used name-only filenames. Read them once for compatibility, // but migrate the next save to the collision-safe preferred path. The old file is @@ -71,7 +72,9 @@ public async Task LoadAsync( var legacy = await TryReadAsync(legacyPath, cancellationToken).ConfigureAwait(false); if (IsForDevice(legacy, device)) { - legacy!.StoragePath = preferredPath; + legacy!.StoragePath = preferredPathOccupied + ? GetNonDestructiveRecoveryPath(preferredPath) + : preferredPath; Normalize(legacy, device); return legacy; } @@ -99,13 +102,19 @@ public async Task LoadAsync( return probed; } + // An occupied preferred path that is unreadable or belongs to a different device + // is evidence, not scratch space. Start a recovery state beside it; never overwrite + // the original simply because deserialization failed. + var storagePath = preferredPathOccupied + ? GetNonDestructiveRecoveryPath(preferredPath) + : preferredPath; return new NativeFatDeviceState { DeviceId = device.DeviceId, IedName = device.Name, CreatedUtc = DateTimeOffset.UtcNow, UpdatedUtc = DateTimeOffset.UtcNow, - StoragePath = preferredPath + StoragePath = storagePath }; } @@ -362,6 +371,21 @@ private string GetPreferredPath(string? iedName, string? deviceId) private string GetLegacyPath(string? iedName) => Path.Combine(_rootDirectory, SanitizeFileStem(iedName) + ".json"); + private static string GetNonDestructiveRecoveryPath(string preferredPath) + { + var directory = Path.GetDirectoryName(preferredPath) ?? string.Empty; + var stem = Path.GetFileNameWithoutExtension(preferredPath); + var extension = Path.GetExtension(preferredPath); + for (var index = 1; index <= 999; index++) + { + var candidate = Path.Combine(directory, $"{stem}__RECOVERY_{index:000}{extension}"); + if (!File.Exists(candidate)) + return candidate; + } + + return Path.Combine(directory, $"{stem}__RECOVERY_{Guid.NewGuid():N}{extension}"); + } + private static string StableDeviceHash(string? deviceId) { if (string.IsNullOrWhiteSpace(deviceId)) From 628d83d24fce8f72a83f2b1a2894fada660eaf94 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 15:45:35 +0700 Subject: [PATCH 12/33] Verify damaged native FAT files remain recoverable --- .../NativeFatStateStoreRegressionTests.cs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/ARSAS.Tests/NativeFatStateStoreRegressionTests.cs b/tests/ARSAS.Tests/NativeFatStateStoreRegressionTests.cs index 30c45d717..43311ae25 100644 --- a/tests/ARSAS.Tests/NativeFatStateStoreRegressionTests.cs +++ b/tests/ARSAS.Tests/NativeFatStateStoreRegressionTests.cs @@ -114,6 +114,30 @@ public async Task SameDisplayNameDifferentDeviceIds_NeverShareOneStateFile() Assert.Equal(NativeFatResult.Fail, Assert.Single(secondReloaded.Signals).Result); } + [Fact] + public async Task DamagedPreferredJson_IsNeverOverwrittenByRecoveryState() + { + using var temp = new TemporaryDirectory(); + var device = CreateDevice("device-corrupt", "Corrupt Evidence IED"); + device.Signals.Add(CreateSignal("DI 1", "IED1LD0/GGIO1.Ind1.stVal")); + + var firstStore = new NativeFatStateStore(temp.Path); + var seed = await firstStore.LoadAsync(device); + var damagedPath = seed.StoragePath; + const string damagedContent = "{ this is intentionally not valid JSON"; + await File.WriteAllTextAsync(damagedPath, damagedContent); + + var recoveryStore = new NativeFatStateStore(temp.Path); + var recovered = await recoveryStore.LoadAndReconcileAsync(device); + Assert.NotEqual(damagedPath, recovered.StoragePath); + Assert.Contains("RECOVERY", Path.GetFileName(recovered.StoragePath), StringComparison.OrdinalIgnoreCase); + + await recoveryStore.SaveAsync(recovered); + Assert.Equal(damagedContent, await File.ReadAllTextAsync(damagedPath)); + Assert.True(File.Exists(recovered.StoragePath)); + Assert.Equal(2, Directory.GetFiles(temp.Path, "*.json").Length); + } + [Fact] public void ReportSnapshot_FreezesEvidenceAndSummaryAtBuildTime() { From babd99468350b84f37fbe04dd11a140b2588ed01 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 15:48:17 +0700 Subject: [PATCH 13/33] Flush native FAT evidence before application shutdown --- MainWindow.NativeFatPersistenceSafety.cs | 67 ++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 MainWindow.NativeFatPersistenceSafety.cs diff --git a/MainWindow.NativeFatPersistenceSafety.cs b/MainWindow.NativeFatPersistenceSafety.cs new file mode 100644 index 000000000..8d7d67a19 --- /dev/null +++ b/MainWindow.NativeFatPersistenceSafety.cs @@ -0,0 +1,67 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows; + +namespace ArIED61850Tester; + +/// +/// Shutdown durability guard for native FAT. The normal 350 ms autosave debounce keeps +/// multi-row operations cheap, but a user can legitimately close ARSAS inside that small +/// window. Flush the current per-IED evidence synchronously during Closing so the last +/// capture/result is not lost before the application cancellation token is triggered. +/// +public partial class MainWindow +{ + private bool _nativeFatPersistenceSafetyAttached; + + [ModuleInitializer] + internal static void RegisterNativeFatPersistenceSafety() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(NativeFatPersistenceSafety_MainWindowLoaded), + handledEventsToo: true); + } + + private static void NativeFatPersistenceSafety_MainWindowLoaded(object sender, RoutedEventArgs e) + { + if (sender is not MainWindow window || window._nativeFatPersistenceSafetyAttached) + return; + + window._nativeFatPersistenceSafetyAttached = true; + window.Closing += window.NativeFatPersistenceSafety_Closing; + window.Closed += window.NativeFatPersistenceSafety_Closed; + } + + private void NativeFatPersistenceSafety_Closing(object? sender, CancelEventArgs e) + { + var state = _nativeFatCurrentState; + if (state == null) + return; + + _nativeFatSaveTimer?.Stop(); + try + { + // Intentionally bypass the UI save gate here. A debounced asynchronous save + // may currently own that gate and need the dispatcher for its continuation; + // waiting for the gate synchronously could deadlock Closing. NativeFatStateStore + // uses unique temp files + atomic replace, so a concurrent same-state flush is + // safe and whichever write lands last still represents the same UI state. + _nativeFatStore.SaveAsync(state, CancellationToken.None).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + // Shutdown must remain possible. The existing in-memory state and any previous + // valid JSON remain intact; record the failure while diagnostics are available. + AddLog("WARN", "Native FAT", $"Final FAT persistence flush failed: {ex.Message}"); + } + } + + private void NativeFatPersistenceSafety_Closed(object? sender, EventArgs e) + { + Closing -= NativeFatPersistenceSafety_Closing; + Closed -= NativeFatPersistenceSafety_Closed; + _nativeFatPersistenceSafetyAttached = false; + } +} From b2b412a865707ec5fe58f487de5fdd512cf87d83 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 15:52:23 +0700 Subject: [PATCH 14/33] Add native FAT PDF evidence export --- Services/NativeFatPdfReportService.cs | 386 ++++++++++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 Services/NativeFatPdfReportService.cs diff --git a/Services/NativeFatPdfReportService.cs b/Services/NativeFatPdfReportService.cs new file mode 100644 index 000000000..7c7fbf768 --- /dev/null +++ b/Services/NativeFatPdfReportService.cs @@ -0,0 +1,386 @@ +using System.Text; +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ArIED61850Tester.Services; + +/// +/// Native FAT PDF export driven by the same immutable snapshot used by the integrated +/// preview. It reuses ARSAS' existing embedded-font PDF serializer, but does not convert +/// the native state into the legacy IoTest evidence runtime or start another acquisition. +/// +public static class NativeFatPdfReportService +{ + private const double PageWidth = 842d; + private const double PageHeight = 595d; + private const double Margin = 30d; + private const double HeaderBottom = 500d; + private const double ContentTop = 484d; + private const double ContentBottom = 54d; + private const double ContentWidth = PageWidth - (Margin * 2d); + + private static readonly IoFatReportColor Navy = IoFatReportColor.FromHex("0F172A"); + private static readonly IoFatReportColor Blue = IoFatReportColor.FromHex("2563EB"); + private static readonly IoFatReportColor Muted = IoFatReportColor.FromHex("64748B"); + private static readonly IoFatReportColor Border = IoFatReportColor.FromHex("DDE7F3"); + private static readonly IoFatReportColor SoftLine = IoFatReportColor.FromHex("EEF2F7"); + private static readonly IoFatReportColor SoftBlue = IoFatReportColor.FromHex("EFF6FF"); + private static readonly IoFatReportColor White = IoFatReportColor.FromHex("FFFFFF"); + private static readonly IoFatReportColor Pass = IoFatReportColor.FromHex("15803D"); + private static readonly IoFatReportColor Review = IoFatReportColor.FromHex("B45309"); + private static readonly IoFatReportColor Fail = IoFatReportColor.FromHex("B91C1C"); + private static readonly IoFatReportColor SoftPass = IoFatReportColor.FromHex("F0FDF4"); + private static readonly IoFatReportColor SoftReview = IoFatReportColor.FromHex("FFFBEB"); + private static readonly IoFatReportColor SoftFail = IoFatReportColor.FromHex("FEF2F2"); + + public static byte[] Generate(NativeFatReportSnapshot snapshot, bool includeHistorical = false) + { + ArgumentNullException.ThrowIfNull(snapshot); + var layout = BuildLayout(snapshot, includeHistorical); + + // IoFatNativePdfWriter only reads this project object for PDF document metadata. + // The report command stream itself comes entirely from NativeFatReportSnapshot. + var metadataProject = new IoTestProject + { + ProjectId = string.IsNullOrWhiteSpace(snapshot.DeviceId) ? snapshot.IedName : snapshot.DeviceId, + SchemaVersion = "ARSAS-NATIVE-FAT-1", + ProjectName = $"ARSAS Native FAT - {snapshot.IedName}" + }; + return IoFatNativePdfWriter.Build(layout, metadataProject); + } + + public static void Save( + string fileName, + NativeFatReportSnapshot snapshot, + bool includeHistorical = false) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fileName); + var bytes = Generate(snapshot, includeHistorical); + var fullPath = Path.GetFullPath(fileName); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + var temporary = fullPath + ".tmp-" + Guid.NewGuid().ToString("N"); + try + { + using (var stream = new FileStream( + temporary, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 16 * 1024, + FileOptions.WriteThrough)) + { + stream.Write(bytes); + stream.Flush(flushToDisk: true); + } + File.Move(temporary, fullPath, overwrite: true); + } + finally + { + if (File.Exists(temporary)) + File.Delete(temporary); + } + } + + internal static IoFatReportLayoutPlan BuildLayout( + NativeFatReportSnapshot snapshot, + bool includeHistorical) + { + var rows = snapshot.Rows + .Where(row => includeHistorical || !row.IsHistorical) + .ToArray(); + var pages = new List(); + var page = NewPage(pages); + var cursorY = ContentTop; + + DrawTableHeader(page, ref cursorY); + var rowNumber = 0; + foreach (var row in rows) + { + rowNumber++; + var cells = BuildCells(row, rowNumber); + var rowHeight = EstimateRowHeight(cells); + if (cursorY - rowHeight < ContentBottom) + { + page = NewPage(pages); + cursorY = ContentTop; + DrawTableHeader(page, ref cursorY); + } + DrawRow(page, cells, rowHeight, ref cursorY); + } + + if (rows.Length == 0) + { + page.Rect(Margin, cursorY, ContentWidth, 38d, 5d, SoftReview, Border, 0.7d); + page.Text(Margin + 12d, cursorY - 23d, ContentWidth - 24d, + "No current native FAT signal is available for this export scope.", + IoFatReportFontKind.Regular, 8d, Review); + } + + var totalPages = pages.Count; + for (var index = 0; index < pages.Count; index++) + DrawPageChrome(pages[index], snapshot, includeHistorical, index + 1, totalPages); + + return new IoFatReportLayoutPlan( + string.IsNullOrWhiteSpace(snapshot.DeviceId) ? snapshot.IedName : snapshot.DeviceId, + snapshot.GeneratedUtc.ToLocalTime(), + Draft: false, + pages.Select((item, index) => new IoFatReportPagePlan( + index + 1, + PageWidth, + PageHeight, + item.Commands.ToArray())) + .ToArray()); + } + + private static PageBuilder NewPage(List pages) + { + var page = new PageBuilder(); + pages.Add(page); + return page; + } + + private static void DrawPageChrome( + PageBuilder page, + NativeFatReportSnapshot snapshot, + bool includeHistorical, + int pageNumber, + int totalPages) + { + var tone = ResolveTone(snapshot); + var toneColor = ToneColor(tone); + var toneBackground = ToneBackground(tone); + + page.Line(Margin, HeaderBottom, PageWidth - Margin, HeaderBottom, Border, 0.8d); + page.Text(Margin, 562d, 440d, "ARSAS | IEC 61850 FAT", IoFatReportFontKind.Bold, 7.4d, Muted); + page.Text(Margin, 540d, 520d, "Native FAT Evidence Report", IoFatReportFontKind.Bold, 20.4d, Navy); + page.Text(Margin, 520d, 590d, + $"{Clean(snapshot.IedName)} | {Clean(snapshot.IpAddress)}:{snapshot.Port} | immutable capture snapshot", + IoFatReportFontKind.Regular, 7.7d, Muted); + + const double cardWidth = 184d; + const double cardHeight = 62d; + var cardX = PageWidth - Margin - cardWidth; + const double cardTop = 568d; + page.Rect(cardX, cardTop, cardWidth, cardHeight, 6d, toneBackground, toneColor, 0.9d); + page.Text(cardX + 11d, cardTop - 15d, cardWidth - 22d, "CURRENT SCOPE", IoFatReportFontKind.Bold, 6.2d, Muted); + page.Text(cardX + 11d, cardTop - 34d, cardWidth - 22d, tone, IoFatReportFontKind.Bold, 14.2d, toneColor); + page.Text(cardX + 11d, cardTop - 50d, cardWidth - 22d, + $"P {snapshot.PassCount} | R {snapshot.ReviewCount} | F {snapshot.FailCount} | U {snapshot.UntestedCount}", + IoFatReportFontKind.Regular, 6.3d, Muted); + + page.Line(Margin, 42d, PageWidth - Margin, 42d, Border, 0.6d); + var historyText = includeHistorical + ? $" | historical included: {snapshot.HistoricalCount}" + : snapshot.HistoricalCount > 0 ? $" | historical omitted: {snapshot.HistoricalCount}" : string.Empty; + page.Text(Margin, 24d, 650d, + $"Generated {snapshot.GeneratedUtc.ToLocalTime():yyyy-MM-dd HH:mm:ss zzz}{historyText} | DeviceId {Clean(snapshot.DeviceId)}", + IoFatReportFontKind.Regular, 6.1d, Muted); + page.Text(PageWidth - Margin - 80d, 24d, 80d, + $"Page {pageNumber} / {totalPages}", IoFatReportFontKind.Regular, 6.1d, Muted); + } + + private static void DrawTableHeader(PageBuilder page, ref double cursorY) + { + var widths = ColumnWidths(); + var headers = new[] { "#", "Signal / IEC identity", "Value 1 evidence", "Value 2 evidence", "Result" }; + var x = Margin; + const double height = 23d; + for (var index = 0; index < headers.Length; index++) + { + page.Rect(x, cursorY, widths[index], height, 0d, SoftBlue, Border, 0.45d); + page.Text(x + 5d, cursorY - 15d, widths[index] - 10d, + headers[index], IoFatReportFontKind.Bold, 6.2d, Blue); + x += widths[index]; + } + cursorY -= height; + } + + private static ReportCell[] BuildCells(NativeFatReportRow row, int rowNumber) + { + var scope = row.IsHistorical ? "HISTORICAL" : "CURRENT"; + var signal = $"{row.SignalName}\n{row.IecReference} [{row.FunctionalConstraint}] | {row.DataType}\n{scope}"; + var v1 = BuildEvidenceCell( + row.Value1Text, + row.Value1Quality, + row.Value1DeviceTimestamp, + row.Value1CapturedText, + row.Value1SourceMode); + var v2 = BuildEvidenceCell( + row.Value2Text, + row.Value2Quality, + row.Value2DeviceTimestamp, + row.Value2CapturedText, + row.Value2SourceMode); + var resultColor = ResultColor(row.Result, row.IsHistorical); + var result = $"{row.Result}\nhistory {row.HistoryText}"; + + return + [ + new ReportCell(rowNumber.ToString(), 6.4d, IoFatReportFontKind.Regular, Muted), + new ReportCell(signal, 6.25d, IoFatReportFontKind.Regular, Navy), + new ReportCell(v1, 6.0d, IoFatReportFontKind.Regular, Navy), + new ReportCell(v2, 6.0d, IoFatReportFontKind.Regular, Navy), + new ReportCell(result, 6.4d, IoFatReportFontKind.Bold, resultColor) + ]; + } + + private static string BuildEvidenceCell( + string value, + string quality, + string iedTimestamp, + string captured, + string source) + { + var cleanValue = Clean(value); + if (cleanValue is "-" or "—") + return "Not captured"; + + return $"{cleanValue}\nq={Clean(quality)} | {Clean(source)}\nIED {Clean(iedTimestamp)}\nARSAS {Clean(captured)}"; + } + + private static double EstimateRowHeight(IReadOnlyList cells) + { + var widths = ColumnWidths(); + var maxLines = 1; + for (var index = 0; index < cells.Count; index++) + maxLines = Math.Max(maxLines, WrapText(cells[index].Text, widths[index] - 10d, cells[index].FontSize).Count); + return Math.Max(38d, 9d + (maxLines * 8.0d)); + } + + private static void DrawRow( + PageBuilder page, + IReadOnlyList cells, + double rowHeight, + ref double cursorY) + { + var widths = ColumnWidths(); + var x = Margin; + for (var index = 0; index < cells.Count; index++) + { + var cell = cells[index]; + page.Rect(x, cursorY, widths[index], rowHeight, 0d, White, SoftLine, 0.35d); + var lines = WrapText(cell.Text, widths[index] - 10d, cell.FontSize); + var y = cursorY - 10d; + foreach (var line in lines) + { + page.Text(x + 5d, y, widths[index] - 10d, + line, cell.Font, cell.FontSize, cell.Color); + y -= 8.0d; + } + x += widths[index]; + } + cursorY -= rowHeight; + } + + private static double[] ColumnWidths() + => [28d, 260d, 180d, 180d, 134d]; + + private static IReadOnlyList WrapText(string? value, double width, double fontSize) + { + var input = (value ?? string.Empty) + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n'); + if (string.IsNullOrWhiteSpace(input)) + return ["-"]; + + var charsPerLine = Math.Max(7, (int)Math.Floor(width / Math.Max(2.4d, fontSize * 0.49d))); + var lines = new List(); + foreach (var paragraphValue in input.Split('\n')) + { + var paragraph = IoFatReportLayoutEngine.SanitizeReportText(paragraphValue); + if (paragraph.Length == 0) + { + lines.Add("-"); + continue; + } + + var words = paragraph.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var current = new StringBuilder(); + foreach (var originalWord in words) + { + var word = originalWord; + while (word.Length > charsPerLine) + { + if (current.Length > 0) + { + lines.Add(current.ToString()); + current.Clear(); + } + lines.Add(word[..charsPerLine]); + word = word[charsPerLine..]; + } + if (word.Length == 0) + continue; + if (current.Length == 0) + current.Append(word); + else if (current.Length + 1 + word.Length <= charsPerLine) + current.Append(' ').Append(word); + else + { + lines.Add(current.ToString()); + current.Clear().Append(word); + } + } + if (current.Length > 0) + lines.Add(current.ToString()); + } + return lines.Count == 0 ? ["-"] : lines; + } + + private static string ResolveTone(NativeFatReportSnapshot snapshot) + { + if (snapshot.CurrentCount == 0) + return "NO CURRENT SIGNALS"; + if (snapshot.FailCount > 0) + return "FAIL"; + if (snapshot.ReviewCount > 0 || snapshot.UntestedCount > 0) + return "REVIEW"; + return snapshot.PassCount == snapshot.CurrentCount ? "PASS" : "REVIEW"; + } + + private static IoFatReportColor ToneColor(string tone) + => tone == "PASS" ? Pass : tone == "FAIL" ? Fail : Review; + + private static IoFatReportColor ToneBackground(string tone) + => tone == "PASS" ? SoftPass : tone == "FAIL" ? SoftFail : SoftReview; + + private static IoFatReportColor ResultColor(string result, bool historical) + { + if (historical) + return Muted; + return result switch + { + NativeFatResult.Pass => Pass, + NativeFatResult.Fail => Fail, + NativeFatResult.Review => Review, + _ => Muted + }; + } + + private static string Clean(string? value) + { + var clean = IoFatReportLayoutEngine.SanitizeReportText(value); + return string.IsNullOrWhiteSpace(clean) ? "-" : clean; + } + + private sealed record ReportCell( + string Text, + double FontSize, + IoFatReportFontKind Font, + IoFatReportColor Color); + + private sealed class PageBuilder + { + public List Commands { get; } = new(); + + public void Text(double x, double y, double width, string text, IoFatReportFontKind font, double size, IoFatReportColor color) + => Commands.Add(new IoFatReportTextCommand(x, y, width, Clean(text), font, size, color)); + + public void Line(double x1, double y1, double x2, double y2, IoFatReportColor stroke, double thickness) + => Commands.Add(new IoFatReportLineCommand(x1, y1, x2, y2, stroke, thickness)); + + public void Rect(double x, double topY, double width, double height, double radius, IoFatReportColor fill, IoFatReportColor stroke, double thickness) + => Commands.Add(new IoFatReportRectCommand(x, topY, width, height, radius, fill, stroke, thickness)); + } +} From 42dafa54c20f5f3a1ba4eb99bb7487c5f700a69a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 15:53:00 +0700 Subject: [PATCH 15/33] Wire native FAT PDF export into engineering workspace --- MainWindow.NativeFatExport.cs | 166 ++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 MainWindow.NativeFatExport.cs diff --git a/MainWindow.NativeFatExport.cs b/MainWindow.NativeFatExport.cs new file mode 100644 index 000000000..426cd18c8 --- /dev/null +++ b/MainWindow.NativeFatExport.cs @@ -0,0 +1,166 @@ +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; +using ArIED61850Tester.Services; +using Microsoft.Win32; + +namespace ArIED61850Tester; + +/// +/// Native evidence export entry point. Export consumes the immutable report snapshot +/// created from the already-loaded FAT state; it never creates a second IED session. +/// +public partial class MainWindow +{ + private DispatcherTimer? _nativeFatExportInstallRetry; + private Button? _nativeFatExportPdfButton; + private bool _nativeFatExportInstalled; + + [ModuleInitializer] + internal static void RegisterNativeFatExport() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(NativeFatExport_MainWindowLoaded), + handledEventsToo: true); + } + + private static void NativeFatExport_MainWindowLoaded(object sender, RoutedEventArgs e) + { + if (sender is not MainWindow window || window._nativeFatExportInstalled) + return; + + window.Dispatcher.BeginInvoke( + DispatcherPriority.ApplicationIdle, + new Action(window.TryInstallNativeFatExport)); + } + + private void TryInstallNativeFatExport() + { + if (_nativeFatExportInstalled || !IsLoaded) + return; + + if (!_nativeFatInstalled || _nativeFatSearchBox?.Parent is not WrapPanel toolbar) + { + _nativeFatExportInstallRetry ??= new DispatcherTimer(DispatcherPriority.ApplicationIdle) + { + Interval = TimeSpan.FromMilliseconds(190) + }; + _nativeFatExportInstallRetry.Tick -= NativeFatExportInstallRetry_Tick; + _nativeFatExportInstallRetry.Tick += NativeFatExportInstallRetry_Tick; + _nativeFatExportInstallRetry.Start(); + return; + } + + _nativeFatExportInstallRetry?.Stop(); + _nativeFatExportInstalled = true; + _nativeFatExportPdfButton = CreateNativeFatButton( + "Export PDF", + NativeFatExportPdf_Click, + "Export an immutable native FAT evidence PDF for the selected IED. Show historical controls whether historical signal rows are included."); + + var historyIndex = _nativeFatShowHistoricalCheck == null + ? toolbar.Children.Count + : toolbar.Children.IndexOf(_nativeFatShowHistoricalCheck); + if (historyIndex < 0) + historyIndex = toolbar.Children.Count; + toolbar.Children.Insert(historyIndex, _nativeFatExportPdfButton); + + Closed += NativeFatExport_MainWindowClosed; + } + + private void NativeFatExportInstallRetry_Tick(object? sender, EventArgs e) + { + _nativeFatExportInstallRetry?.Stop(); + TryInstallNativeFatExport(); + } + + private async void NativeFatExportPdf_Click(object sender, RoutedEventArgs e) + { + if (_nativeFatExportPdfButton == null) + return; + + await EnsureNativeFatLoadedAsync(forceReconcile: false); + var device = SelectedDevice; + var state = _nativeFatCurrentState; + if (device == null || state == null || + !state.DeviceId.Equals(device.DeviceId, StringComparison.OrdinalIgnoreCase)) + { + SetNativeFatStatus("Select an IED with a loaded FAT state before exporting."); + return; + } + + var includeHistorical = _nativeFatShowHistoricalCheck?.IsChecked == true; + var snapshot = NativeFatReportSnapshotBuilder.Build(device, state); + var dialog = new SaveFileDialog + { + Title = "Export ARSAS native FAT evidence PDF", + Filter = "PDF evidence report (*.pdf)|*.pdf", + FileName = $"{SafeNativeFatFileName(device.Name)}_FAT_{DateTime.Now:yyyyMMdd_HHmm}.pdf", + AddExtension = true, + DefaultExt = ".pdf", + OverwritePrompt = true + }; + if (dialog.ShowDialog(this) != true) + return; + + var previousContent = _nativeFatExportPdfButton.Content; + try + { + _nativeFatExportPdfButton.IsEnabled = false; + _nativeFatExportPdfButton.Content = "Exporting…"; + SetNativeFatStatus("Saving current FAT state and building evidence PDF…"); + + // Persist the exact state used to construct the snapshot before delivering + // an external evidence file. The snapshot itself remains frozen thereafter. + await SaveNativeFatStateAsync(state); + await Task.Run(() => NativeFatPdfReportService.Save( + dialog.FileName, + snapshot, + includeHistorical)); + + SetNativeFatStatus($"PDF exported · {Path.GetFileName(dialog.FileName)}"); + AddLog( + "INFO", + "Native FAT", + $"Native FAT PDF exported for {device.Name}: {dialog.FileName}" + + (includeHistorical ? " (historical included)" : string.Empty)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException or ArgumentException) + { + AddLog("WARN", "Native FAT", $"PDF export failed: {ex.Message}"); + SetNativeFatStatus("PDF export failed. Native FAT state remains saved."); + MessageBox.Show( + this, + ex.Message, + "Native FAT PDF export failed", + MessageBoxButton.OK, + MessageBoxImage.Error); + } + finally + { + _nativeFatExportPdfButton.Content = previousContent ?? "Export PDF"; + _nativeFatExportPdfButton.IsEnabled = true; + } + } + + private static string SafeNativeFatFileName(string? value) + { + var source = string.IsNullOrWhiteSpace(value) ? "IED" : value.Trim(); + var invalid = Path.GetInvalidFileNameChars().ToHashSet(); + var result = new string(source.Select(character => invalid.Contains(character) ? '_' : character).ToArray()) + .Trim() + .Trim('.'); + return string.IsNullOrWhiteSpace(result) ? "IED" : result; + } + + private void NativeFatExport_MainWindowClosed(object? sender, EventArgs e) + { + _nativeFatExportInstallRetry?.Stop(); + if (_nativeFatExportPdfButton != null) + _nativeFatExportPdfButton.Click -= NativeFatExportPdf_Click; + Closed -= NativeFatExport_MainWindowClosed; + } +} From 8fd1c8e31897d3bb6e76627f8053b8b5cb876edf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 15:53:19 +0700 Subject: [PATCH 16/33] Cover native FAT PDF evidence export --- tests/ARSAS.Tests/NativeFatPdfReportTests.cs | 72 ++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tests/ARSAS.Tests/NativeFatPdfReportTests.cs diff --git a/tests/ARSAS.Tests/NativeFatPdfReportTests.cs b/tests/ARSAS.Tests/NativeFatPdfReportTests.cs new file mode 100644 index 000000000..9f6c85fe3 --- /dev/null +++ b/tests/ARSAS.Tests/NativeFatPdfReportTests.cs @@ -0,0 +1,72 @@ +using System.Text; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class NativeFatPdfReportTests +{ + [Fact] + public void Generate_ProducesPdfFromImmutableNativeSnapshot() + { + var snapshot = new NativeFatReportSnapshot( + DeviceId: "pdf-device-01", + IedName: "Relay PDF", + IpAddress: "192.0.2.55", + Port: 102, + GeneratedUtc: new DateTimeOffset(2026, 9, 9, 9, 15, 0, TimeSpan.Zero), + Rows: + [ + new NativeFatReportRow( + SignalName: "Breaker status", + IecReference: "IED1LD0/GGIO1.Ind1.stVal", + FunctionalConstraint: "ST", + DataType: "Boolean", + IsHistorical: false, + Result: NativeFatResult.Pass, + HistoryCount: 2, + Value1Text: "False", + Value1Quality: "Good", + Value1DeviceTimestamp: "2026-09-09 09:00:00.001", + Value1SourceMode: "BRCB", + Value1CapturedUtc: new DateTimeOffset(2026, 9, 9, 9, 0, 1, TimeSpan.Zero), + Value2Text: "True", + Value2Quality: "Good", + Value2DeviceTimestamp: "2026-09-09 09:00:05.002", + Value2SourceMode: "BRCB", + Value2CapturedUtc: new DateTimeOffset(2026, 9, 9, 9, 0, 6, TimeSpan.Zero)), + new NativeFatReportRow( + SignalName: "Removed old DI", + IecReference: "IED1LD0/GGIO1.Ind9.stVal", + FunctionalConstraint: "ST", + DataType: "Boolean", + IsHistorical: true, + Result: NativeFatResult.Fail, + HistoryCount: 4, + Value1Text: "False", + Value1Quality: "Good", + Value1DeviceTimestamp: "2026-09-08 08:00:00.001", + Value1SourceMode: "MMS polling", + Value1CapturedUtc: new DateTimeOffset(2026, 9, 8, 8, 0, 1, TimeSpan.Zero), + Value2Text: "True", + Value2Quality: "Good", + Value2DeviceTimestamp: "2026-09-08 08:00:04.001", + Value2SourceMode: "MMS polling", + Value2CapturedUtc: new DateTimeOffset(2026, 9, 8, 8, 0, 5, TimeSpan.Zero)) + ]); + + var currentOnly = NativeFatPdfReportService.Generate(snapshot, includeHistorical: false); + var withHistory = NativeFatPdfReportService.Generate(snapshot, includeHistorical: true); + + Assert.True(currentOnly.Length > 4_000); + Assert.True(withHistory.Length > currentOnly.Length); + Assert.Equal("%PDF-1.4", Encoding.ASCII.GetString(currentOnly, 0, 8)); + + var ascii = Encoding.ASCII.GetString(withHistory); + Assert.Contains("ARSAS Native FAT - Relay PDF - IEC 61850 FAT Evidence Report", ascii, StringComparison.Ordinal); + Assert.Contains("Breaker status", ascii, StringComparison.Ordinal); + Assert.Contains("Removed old DI", ascii, StringComparison.Ordinal); + Assert.Contains("historical included: 1", ascii, StringComparison.OrdinalIgnoreCase); + Assert.Contains("%%EOF", ascii, StringComparison.Ordinal); + } +} From 8b7af6a4f0d4509cbc26bb6eff9f5ed2fd1dfd8e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 16:30:15 +0700 Subject: [PATCH 17/33] Harden FAT persistence across IED switches and shutdown --- MainWindow.NativeFatPersistenceSafety.cs | 93 +++++++++++++++++++----- 1 file changed, 73 insertions(+), 20 deletions(-) diff --git a/MainWindow.NativeFatPersistenceSafety.cs b/MainWindow.NativeFatPersistenceSafety.cs index 8d7d67a19..4f7e192b2 100644 --- a/MainWindow.NativeFatPersistenceSafety.cs +++ b/MainWindow.NativeFatPersistenceSafety.cs @@ -1,18 +1,26 @@ using System.ComponentModel; using System.Runtime.CompilerServices; using System.Windows; +using System.Windows.Threading; namespace ArIED61850Tester; /// -/// Shutdown durability guard for native FAT. The normal 350 ms autosave debounce keeps -/// multi-row operations cheap, but a user can legitimately close ARSAS inside that small -/// window. Flush the current per-IED evidence synchronously during Closing so the last -/// capture/result is not lost before the application cancellation token is triggered. +/// Persistence durability guard for native FAT. +/// +/// The normal 350 ms autosave debounce keeps multi-row operations cheap, but two short +/// windows still need explicit protection: +/// 1) the operator switches IED before the debounce fires; and +/// 2) the operator closes ARSAS immediately after a capture/result change. +/// +/// Native FAT caches one state object per stable IED. Flush inactive cached states after +/// a SelectedDevice change and flush every cached state synchronously during Closing so +/// evidence from the previously selected relay cannot be stranded in memory. /// public partial class MainWindow { private bool _nativeFatPersistenceSafetyAttached; + private bool _nativeFatSwitchFlushQueued; [ModuleInitializer] internal static void RegisterNativeFatPersistenceSafety() @@ -30,38 +38,83 @@ private static void NativeFatPersistenceSafety_MainWindowLoaded(object sender, R return; window._nativeFatPersistenceSafetyAttached = true; + window.PropertyChanged += window.NativeFatPersistenceSafety_PropertyChanged; window.Closing += window.NativeFatPersistenceSafety_Closing; window.Closed += window.NativeFatPersistenceSafety_Closed; } - private void NativeFatPersistenceSafety_Closing(object? sender, CancelEventArgs e) + private void NativeFatPersistenceSafety_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(SelectedDevice) || !_nativeFatInstalled || _nativeFatSwitchFlushQueued) + return; + + // Let the normal SelectedDevice handler finish rebinding the workspace first. + // The previous state remains in _nativeFatStateCache, so a ContextIdle flush can + // save it without blocking the IED switch or relying on _nativeFatCurrentState. + _nativeFatSwitchFlushQueued = true; + Dispatcher.BeginInvoke( + DispatcherPriority.ContextIdle, + new Action(() => + { + _nativeFatSwitchFlushQueued = false; + _ = NativeFatPersistenceSafety_FlushInactiveStatesAsync(); + })); + } + + private async Task NativeFatPersistenceSafety_FlushInactiveStatesAsync() { - var state = _nativeFatCurrentState; - if (state == null) + if (_nativeFatStateCache.Count == 0) return; + var current = _nativeFatCurrentState; + var inactive = _nativeFatStateCache.Values + .Where(state => !ReferenceEquals(state, current)) + .Distinct() + .ToArray(); + + foreach (var state in inactive) + await SaveNativeFatStateAsync(state); + } + + private void NativeFatPersistenceSafety_Closing(object? sender, CancelEventArgs e) + { _nativeFatSaveTimer?.Stop(); - try - { - // Intentionally bypass the UI save gate here. A debounced asynchronous save - // may currently own that gate and need the dispatcher for its continuation; - // waiting for the gate synchronously could deadlock Closing. NativeFatStateStore - // uses unique temp files + atomic replace, so a concurrent same-state flush is - // safe and whichever write lands last still represents the same UI state. - _nativeFatStore.SaveAsync(state, CancellationToken.None).GetAwaiter().GetResult(); - } - catch (Exception ex) + + var states = _nativeFatStateCache.Values.Distinct().ToList(); + if (_nativeFatCurrentState != null && !states.Contains(_nativeFatCurrentState)) + states.Add(_nativeFatCurrentState); + if (states.Count == 0) + return; + + foreach (var state in states) { - // Shutdown must remain possible. The existing in-memory state and any previous - // valid JSON remain intact; record the failure while diagnostics are available. - AddLog("WARN", "Native FAT", $"Final FAT persistence flush failed: {ex.Message}"); + try + { + // Intentionally bypass the UI save gate here. A debounced asynchronous + // save may currently own that gate and need the dispatcher for its + // continuation; waiting for the gate synchronously could deadlock Closing. + // NativeFatStateStore uses unique temp files + atomic replace, so a + // concurrent same-state flush is safe and never truncates the valid file. + _nativeFatStore.SaveAsync(state, CancellationToken.None).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + // Shutdown must remain possible. Any previous valid JSON is preserved; + // record the specific IED failure while diagnostics are still available. + AddLog( + "WARN", + "Native FAT", + $"Final FAT persistence flush failed for {state.IedName}: {ex.Message}"); + } } } private void NativeFatPersistenceSafety_Closed(object? sender, EventArgs e) { + PropertyChanged -= NativeFatPersistenceSafety_PropertyChanged; Closing -= NativeFatPersistenceSafety_Closing; Closed -= NativeFatPersistenceSafety_Closed; _nativeFatPersistenceSafetyAttached = false; + _nativeFatSwitchFlushQueued = false; } } From eec5507dee18ad1c7a0aa1b12874a8e07ead503d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 16:30:56 +0700 Subject: [PATCH 18/33] Keep native FAT scope synchronized with Explorer signal selection --- MainWindow.NativeFatExplorerSync.cs | 213 ++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 MainWindow.NativeFatExplorerSync.cs diff --git a/MainWindow.NativeFatExplorerSync.cs b/MainWindow.NativeFatExplorerSync.cs new file mode 100644 index 000000000..8ea74646d --- /dev/null +++ b/MainWindow.NativeFatExplorerSync.cs @@ -0,0 +1,213 @@ +using System.Collections.Specialized; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +/// +/// Keeps the native FAT projection aligned with the persistent IED Explorer's signal +/// selection without turning FAT into a second signal database. +/// +/// ObservableCollection changes were already reconciled by the first P2 slice. Explorer +/// checkbox changes are different: SignalDefinition stays in the collection and only +/// IsSelected changes. This bridge observes that property, marks the FAT scope dirty, and +/// forces non-destructive reconciliation when FAT is visible (or the next time it opens). +/// +public partial class MainWindow +{ + private DispatcherTimer? _nativeFatExplorerSyncInstallRetry; + private DispatcherTimer? _nativeFatExplorerScopeTimer; + private Iec61850MonitorDevice? _nativeFatExplorerSyncDevice; + private readonly HashSet _nativeFatExplorerObservedSignals = new(); + private bool _nativeFatExplorerSyncAttached; + private bool _nativeFatExplorerScopeDirty; + + [ModuleInitializer] + internal static void RegisterNativeFatExplorerSelectionSync() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(NativeFatExplorerSync_MainWindowLoaded), + handledEventsToo: true); + } + + private static void NativeFatExplorerSync_MainWindowLoaded(object sender, RoutedEventArgs e) + { + if (sender is not MainWindow window || window._nativeFatExplorerSyncAttached) + return; + + window.Dispatcher.BeginInvoke( + DispatcherPriority.ApplicationIdle, + new Action(window.TryAttachNativeFatExplorerSelectionSync)); + } + + private void TryAttachNativeFatExplorerSelectionSync() + { + if (_nativeFatExplorerSyncAttached || !IsLoaded) + return; + + if (!_nativeFatInstalled) + { + _nativeFatExplorerSyncInstallRetry ??= new DispatcherTimer(DispatcherPriority.ApplicationIdle) + { + Interval = TimeSpan.FromMilliseconds(180) + }; + _nativeFatExplorerSyncInstallRetry.Tick -= NativeFatExplorerSyncInstallRetry_Tick; + _nativeFatExplorerSyncInstallRetry.Tick += NativeFatExplorerSyncInstallRetry_Tick; + _nativeFatExplorerSyncInstallRetry.Start(); + return; + } + + _nativeFatExplorerSyncInstallRetry?.Stop(); + _nativeFatExplorerSyncAttached = true; + + _nativeFatExplorerScopeTimer = new DispatcherTimer(DispatcherPriority.Background) + { + Interval = TimeSpan.FromMilliseconds(180) + }; + _nativeFatExplorerScopeTimer.Tick += NativeFatExplorerScopeTimer_Tick; + + PropertyChanged += NativeFatExplorerSync_MainWindowPropertyChanged; + MainTabs.SelectionChanged += NativeFatExplorerSync_MainTabsSelectionChanged; + Closed += NativeFatExplorerSync_MainWindowClosed; + RebindNativeFatExplorerSignals(SelectedDevice); + } + + private void NativeFatExplorerSyncInstallRetry_Tick(object? sender, EventArgs e) + { + _nativeFatExplorerSyncInstallRetry?.Stop(); + TryAttachNativeFatExplorerSelectionSync(); + } + + private void NativeFatExplorerSync_MainWindowPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(SelectedDevice)) + return; + + RebindNativeFatExplorerSignals(SelectedDevice); + MarkNativeFatExplorerScopeDirty(); + } + + private void RebindNativeFatExplorerSignals(Iec61850MonitorDevice? device) + { + if (ReferenceEquals(_nativeFatExplorerSyncDevice, device)) + { + SyncNativeFatExplorerSignalSubscriptions(); + return; + } + + if (_nativeFatExplorerSyncDevice != null) + _nativeFatExplorerSyncDevice.Signals.CollectionChanged -= NativeFatExplorerSignals_CollectionChanged; + + foreach (var signal in _nativeFatExplorerObservedSignals) + signal.PropertyChanged -= NativeFatExplorerSignal_PropertyChanged; + _nativeFatExplorerObservedSignals.Clear(); + + _nativeFatExplorerSyncDevice = device; + if (_nativeFatExplorerSyncDevice != null) + { + _nativeFatExplorerSyncDevice.Signals.CollectionChanged += NativeFatExplorerSignals_CollectionChanged; + SyncNativeFatExplorerSignalSubscriptions(); + } + } + + private void NativeFatExplorerSignals_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + SyncNativeFatExplorerSignalSubscriptions(); + MarkNativeFatExplorerScopeDirty(); + } + + private void SyncNativeFatExplorerSignalSubscriptions() + { + var current = _nativeFatExplorerSyncDevice?.Signals.ToHashSet() ?? new HashSet(); + + foreach (var signal in _nativeFatExplorerObservedSignals.Where(signal => !current.Contains(signal)).ToArray()) + { + signal.PropertyChanged -= NativeFatExplorerSignal_PropertyChanged; + _nativeFatExplorerObservedSignals.Remove(signal); + } + + foreach (var signal in current) + { + if (!_nativeFatExplorerObservedSignals.Add(signal)) + continue; + signal.PropertyChanged += NativeFatExplorerSignal_PropertyChanged; + } + } + + private void NativeFatExplorerSignal_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + // IsSelected is the Explorer membership authority. Value/quality/timestamp changes + // stay live-bound directly and must never trigger a full FAT reconciliation. + if (e.PropertyName == nameof(SignalDefinition.IsSelected)) + MarkNativeFatExplorerScopeDirty(); + } + + private void MarkNativeFatExplorerScopeDirty() + { + _nativeFatExplorerScopeDirty = true; + if (MainTabs.SelectedIndex != NativeFatWorkspaceIndex) + return; + + _nativeFatExplorerScopeTimer?.Stop(); + _nativeFatExplorerScopeTimer?.Start(); + } + + private async void NativeFatExplorerScopeTimer_Tick(object? sender, EventArgs e) + { + _nativeFatExplorerScopeTimer?.Stop(); + if (!_nativeFatExplorerScopeDirty || MainTabs.SelectedIndex != NativeFatWorkspaceIndex) + return; + + await EnsureNativeFatLoadedAsync(forceReconcile: true); + _nativeFatExplorerScopeDirty = false; + } + + private void NativeFatExplorerSync_MainTabsSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (!ReferenceEquals(e.Source, MainTabs) || + MainTabs.SelectedIndex != NativeFatWorkspaceIndex || + !_nativeFatExplorerScopeDirty) + { + return; + } + + // The base P2 selection handler may have issued a fast non-forced load already. + // Re-run at ContextIdle with forceReconcile so checkbox changes made while another + // workspace was active are never hidden behind the current-state fast path. + Dispatcher.BeginInvoke( + DispatcherPriority.ContextIdle, + new Action(async () => + { + if (MainTabs.SelectedIndex != NativeFatWorkspaceIndex || !_nativeFatExplorerScopeDirty) + return; + await EnsureNativeFatLoadedAsync(forceReconcile: true); + _nativeFatExplorerScopeDirty = false; + })); + } + + private void NativeFatExplorerSync_MainWindowClosed(object? sender, EventArgs e) + { + _nativeFatExplorerSyncInstallRetry?.Stop(); + _nativeFatExplorerScopeTimer?.Stop(); + + PropertyChanged -= NativeFatExplorerSync_MainWindowPropertyChanged; + MainTabs.SelectionChanged -= NativeFatExplorerSync_MainTabsSelectionChanged; + Closed -= NativeFatExplorerSync_MainWindowClosed; + + if (_nativeFatExplorerSyncDevice != null) + _nativeFatExplorerSyncDevice.Signals.CollectionChanged -= NativeFatExplorerSignals_CollectionChanged; + foreach (var signal in _nativeFatExplorerObservedSignals) + signal.PropertyChanged -= NativeFatExplorerSignal_PropertyChanged; + + _nativeFatExplorerObservedSignals.Clear(); + _nativeFatExplorerSyncDevice = null; + _nativeFatExplorerSyncAttached = false; + _nativeFatExplorerScopeDirty = false; + } +} From 2c3eac3c0d14c66465f1723e913617bf6ec8e2ad Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 16:31:36 +0700 Subject: [PATCH 19/33] Add auditable native FAT history inspector --- MainWindow.NativeFatHistoryInspector.cs | 178 ++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 MainWindow.NativeFatHistoryInspector.cs diff --git a/MainWindow.NativeFatHistoryInspector.cs b/MainWindow.NativeFatHistoryInspector.cs new file mode 100644 index 000000000..27b24d82c --- /dev/null +++ b/MainWindow.NativeFatHistoryInspector.cs @@ -0,0 +1,178 @@ +using System.Runtime.CompilerServices; +using System.Text; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +/// +/// Makes the native FAT report preview useful as an operator evidence/history inspector. +/// The immutable report snapshot remains the preview/export authority; this enhancement +/// only appends the persisted chronological audit trail for the selected IEC identity. +/// +public partial class MainWindow +{ + private DispatcherTimer? _nativeFatHistoryInspectorInstallRetry; + private ScrollViewer? _nativeFatHistoryScrollViewer; + private bool _nativeFatHistoryInspectorAttached; + private bool _nativeFatHistoryRenderQueued; + + [ModuleInitializer] + internal static void RegisterNativeFatHistoryInspector() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(NativeFatHistoryInspector_MainWindowLoaded), + handledEventsToo: true); + } + + private static void NativeFatHistoryInspector_MainWindowLoaded(object sender, RoutedEventArgs e) + { + if (sender is not MainWindow window || window._nativeFatHistoryInspectorAttached) + return; + + window.Dispatcher.BeginInvoke( + DispatcherPriority.ApplicationIdle, + new Action(window.TryAttachNativeFatHistoryInspector)); + } + + private void TryAttachNativeFatHistoryInspector() + { + if (_nativeFatHistoryInspectorAttached || !IsLoaded) + return; + + if (!_nativeFatReportPreviewEnhanced || + _nativeFatPreviewPane?.Child is not Grid previewRoot || + _nativeFatPreviewGrid == null || + _nativeFatPreviewEvidenceText == null) + { + _nativeFatHistoryInspectorInstallRetry ??= new DispatcherTimer(DispatcherPriority.ApplicationIdle) + { + Interval = TimeSpan.FromMilliseconds(180) + }; + _nativeFatHistoryInspectorInstallRetry.Tick -= NativeFatHistoryInspectorInstallRetry_Tick; + _nativeFatHistoryInspectorInstallRetry.Tick += NativeFatHistoryInspectorInstallRetry_Tick; + _nativeFatHistoryInspectorInstallRetry.Start(); + return; + } + + _nativeFatHistoryInspectorInstallRetry?.Stop(); + _nativeFatHistoryInspectorAttached = true; + + // The evidence block can become long once retest history exists. Keep the report + // pane compact and give only the evidence/history area its own vertical scrolling. + if (previewRoot.Children.Contains(_nativeFatPreviewEvidenceText)) + previewRoot.Children.Remove(_nativeFatPreviewEvidenceText); + + _nativeFatPreviewEvidenceText.Margin = new Thickness(0); + _nativeFatPreviewEvidenceText.TextWrapping = TextWrapping.Wrap; + _nativeFatHistoryScrollViewer = new ScrollViewer + { + Content = _nativeFatPreviewEvidenceText, + Margin = new Thickness(0, 8, 0, 0), + MaxHeight = 190, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, + CanContentScroll = false + }; + Grid.SetRow(_nativeFatHistoryScrollViewer, 5); + previewRoot.Children.Add(_nativeFatHistoryScrollViewer); + + _nativeFatPreviewGrid.SelectionChanged += NativeFatHistoryInspector_SelectionChanged; + Closed += NativeFatHistoryInspector_MainWindowClosed; + QueueNativeFatHistoryInspectorRender(); + } + + private void NativeFatHistoryInspectorInstallRetry_Tick(object? sender, EventArgs e) + { + _nativeFatHistoryInspectorInstallRetry?.Stop(); + TryAttachNativeFatHistoryInspector(); + } + + private void NativeFatHistoryInspector_SelectionChanged(object sender, SelectionChangedEventArgs e) + => QueueNativeFatHistoryInspectorRender(); + + private void QueueNativeFatHistoryInspectorRender() + { + if (!_nativeFatHistoryInspectorAttached || _nativeFatHistoryRenderQueued) + return; + + _nativeFatHistoryRenderQueued = true; + Dispatcher.BeginInvoke( + DispatcherPriority.ContextIdle, + new Action(() => + { + _nativeFatHistoryRenderQueued = false; + RenderNativeFatHistoryInspector(); + })); + } + + private void RenderNativeFatHistoryInspector() + { + if (_nativeFatPreviewEvidenceText == null) + return; + + if (_nativeFatPreviewGrid?.SelectedItem is not NativeFatReportRow reportRow) + { + _nativeFatPreviewEvidenceText.Text = "Select a report row to inspect captured evidence and retest history."; + return; + } + + var text = new StringBuilder(reportRow.EvidenceSummaryText); + var state = _nativeFatCurrentState; + var key = NativeFatIdentity.BuildKey(reportRow.IecReference, reportRow.FunctionalConstraint); + var persisted = state?.Signals.FirstOrDefault(signal => + signal.Key.Equals(key, StringComparison.OrdinalIgnoreCase)); + var history = persisted?.History ?? new List(); + + text.Append("\n\nHISTORY · latest first"); + if (history.Count == 0) + { + text.Append("\nNo previous capture/result transitions recorded."); + } + else + { + foreach (var entry in history + .OrderByDescending(item => item.TimestampUtc) + .Take(8)) + { + text.Append("\n") + .Append(entry.TimestampUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")) + .Append(" · ") + .Append(string.IsNullOrWhiteSpace(entry.Action) ? "State update" : entry.Action.Trim()); + + if (!string.IsNullOrWhiteSpace(entry.Result)) + text.Append(" · ").Append(entry.Result.Trim()); + if (entry.Value1 != null) + text.Append(" · V1=").Append(entry.Value1.Value); + if (entry.Value2 != null) + text.Append(" · V2=").Append(entry.Value2.Value); + if (!string.IsNullOrWhiteSpace(entry.Note)) + text.Append(" · ").Append(entry.Note.Trim()); + } + + if (history.Count > 8) + text.Append("\n+").Append(history.Count - 8).Append(" earlier record(s) retained in the per-IED JSON."); + } + + if (reportRow.IsHistorical) + text.Append("\n\nThis IEC identity is historical: it is no longer in the current Explorer scope, but its FAT evidence is retained."); + + _nativeFatPreviewEvidenceText.Text = text.ToString(); + _nativeFatHistoryScrollViewer?.ScrollToTop(); + } + + private void NativeFatHistoryInspector_MainWindowClosed(object? sender, EventArgs e) + { + _nativeFatHistoryInspectorInstallRetry?.Stop(); + if (_nativeFatPreviewGrid != null) + _nativeFatPreviewGrid.SelectionChanged -= NativeFatHistoryInspector_SelectionChanged; + Closed -= NativeFatHistoryInspector_MainWindowClosed; + _nativeFatHistoryInspectorAttached = false; + _nativeFatHistoryRenderQueued = false; + _nativeFatHistoryScrollViewer = null; + } +} From 7dfec8bc0fed5fb34df44a95e376d1a25ea2ee57 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 16:32:23 +0700 Subject: [PATCH 20/33] Cover Explorer selection continuity in native FAT regression tests --- .../NativeFatStateStoreRegressionTests.cs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/ARSAS.Tests/NativeFatStateStoreRegressionTests.cs b/tests/ARSAS.Tests/NativeFatStateStoreRegressionTests.cs index 43311ae25..c0b3db44b 100644 --- a/tests/ARSAS.Tests/NativeFatStateStoreRegressionTests.cs +++ b/tests/ARSAS.Tests/NativeFatStateStoreRegressionTests.cs @@ -54,6 +54,56 @@ public void Reconcile_AddRenameRemoveReadd_PreservesExistingEvidence() Assert.Equal("True", saved.Value1?.Value); } + [Fact] + public void Reconcile_ExplorerSelectionChange_PreservesEvidenceAndRestoresOnReselect() + { + var device = CreateDevice("device-selection", "Selection IED"); + var first = CreateSignal("DI 1", "IED1LD0/GGIO1.Ind1.stVal"); + var second = CreateSignal("DI 2", "IED1LD0/GGIO1.Ind2.stVal"); + device.Signals.Add(first); + device.Signals.Add(second); + + var state = new NativeFatDeviceState(); + NativeFatStateStore.Reconcile(state, device); + Assert.Equal(2, state.Signals.Count); + + var firstState = state.Signals.Single(signal => + signal.Key == NativeFatIdentity.BuildKey(first)); + firstState.Result = NativeFatResult.Pass; + firstState.Value1 = new NativeFatCapture + { + Value = "False", + Quality = "Good", + SourceMode = "BRCB" + }; + + // Keep DI 2 selected so Explorer has an explicit selected-signal scope. DI 1 must + // become historical rather than being deleted when its checkbox is cleared. + first.IsSelected = false; + NativeFatStateStore.Reconcile(state, device); + + firstState = state.Signals.Single(signal => + signal.Key == NativeFatIdentity.BuildKey(first)); + var secondState = state.Signals.Single(signal => + signal.Key == NativeFatIdentity.BuildKey(second)); + Assert.True(firstState.IsHistorical); + Assert.False(secondState.IsHistorical); + Assert.Equal(NativeFatResult.Pass, firstState.Result); + Assert.Equal("False", firstState.Value1?.Value); + + // Re-selecting the same canonical IEC identity resumes the old evidence instead of + // starting a new UNTESTED record. + first.IsSelected = true; + NativeFatStateStore.Reconcile(state, device); + + firstState = state.Signals.Single(signal => + signal.Key == NativeFatIdentity.BuildKey(first)); + Assert.False(firstState.IsHistorical); + Assert.Equal(NativeFatResult.Pass, firstState.Result); + Assert.Equal("False", firstState.Value1?.Value); + Assert.Equal(2, state.Signals.Count); + } + [Fact] public async Task SaveLoad_RestartAndIedRename_ResumeByStableDeviceId() { From ea736d0c01f4f817a057d4dfbb857442f07af557 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 18:49:15 +0700 Subject: [PATCH 21/33] Pivot FAT tab to production workspace host --- MainWindow.ProductionFatTab.cs | 280 +++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 MainWindow.ProductionFatTab.cs diff --git a/MainWindow.ProductionFatTab.cs b/MainWindow.ProductionFatTab.cs new file mode 100644 index 000000000..76aa55785 --- /dev/null +++ b/MainWindow.ProductionFatTab.cs @@ -0,0 +1,280 @@ +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Threading; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +/// +/// P2 FAT pivot: the Engineering FAT tab is a host for the proven production +/// IoListTestingWindow workspace rather than a second/manual FAT implementation. +/// The global Engineering IED Explorer and shared Command Dock remain authoritative. +/// +public partial class MainWindow +{ + private bool _productionFatTabInstalled; + private IoListTestingWindow? _productionFatWindow; + private FrameworkElement? _productionFatSurface; + private DispatcherTimer? _productionFatInstallRetry; + + internal bool ProductionFatTabReady => _productionFatTabInstalled && _nativeFatTab != null; + + [ModuleInitializer] + internal static void RegisterProductionFatTabPivot() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(ProductionFatTab_MainWindowLoaded), + handledEventsToo: true); + } + + private static void ProductionFatTab_MainWindowLoaded(object sender, RoutedEventArgs e) + { + if (sender is not MainWindow window || window._productionFatTabInstalled) + return; + + window.Dispatcher.BeginInvoke( + DispatcherPriority.ApplicationIdle, + new Action(window.TryInstallProductionFatTabPivot)); + } + + private void TryInstallProductionFatTabPivot() + { + if (_productionFatTabInstalled || !IsLoaded) + return; + + // PR #290 creates the seventh navigation slot. Wait until that shell contribution + // exists, then replace only its FAT content/handlers; P0/P1 shell geometry is untouched. + if (!_nativeFatInstalled || _nativeFatTab == null || _nativeFatNavButton == null) + { + _productionFatInstallRetry ??= new DispatcherTimer(DispatcherPriority.ApplicationIdle) + { + Interval = TimeSpan.FromMilliseconds(120) + }; + _productionFatInstallRetry.Tick -= ProductionFatInstallRetry_Tick; + _productionFatInstallRetry.Tick += ProductionFatInstallRetry_Tick; + _productionFatInstallRetry.Start(); + return; + } + + _productionFatInstallRetry?.Stop(); + _productionFatTabInstalled = true; + + // Retire the experimental manual-capture surface. Keep its code isolated in the + // branch for now so this pivot is reversible while production FAT parity is verified. + _nativeFatReconcileTimer?.Stop(); + _nativeFatSaveTimer?.Stop(); + AttachNativeFatObservedDevice(null); + PropertyChanged -= NativeFat_MainWindowPropertyChanged; + MainTabs.SelectionChanged -= NativeFat_MainTabsSelectionChanged; + _nativeFatNavButton.Click -= NativeFatNavButton_Click; + + _nativeFatTab.Content = BuildProductionFatLauncher(); + + // FAT must be visually indistinguishable from the existing workflow tabs. + _nativeFatNavButton.Style = NavDiagnosticsButton.Style; + _nativeFatNavButton.Padding = NavDiagnosticsButton.Padding; + _nativeFatNavButton.Margin = NavDiagnosticsButton.Margin; + _nativeFatNavButton.HorizontalContentAlignment = NavDiagnosticsButton.HorizontalContentAlignment; + _nativeFatNavButton.VerticalContentAlignment = NavDiagnosticsButton.VerticalContentAlignment; + _nativeFatNavButton.ToolTip = "Production FAT workspace · automatic Value 1 / Value 2 evidence capture"; + _nativeFatNavButton.Click += ProductionFatNavButton_Click; + + PropertyChanged += ProductionFat_MainWindowPropertyChanged; + MainTabs.SelectionChanged += ProductionFat_MainTabsSelectionChanged; + Closed += ProductionFat_MainWindowClosed; + + QueueNativeFatNavigationGeometry(); + } + + private void ProductionFatInstallRetry_Tick(object? sender, EventArgs e) + { + _productionFatInstallRetry?.Stop(); + TryInstallProductionFatTabPivot(); + } + + private FrameworkElement BuildProductionFatLauncher() + { + var root = new Grid { Margin = new Thickness(0) }; + var card = new Border + { + MaxWidth = 720, + Padding = new Thickness(28, 24, 28, 24), + CornerRadius = new CornerRadius(16), + Background = TryFindResource("Surface") as Brush ?? Brushes.White, + BorderBrush = TryFindResource("Line") as Brush ?? new SolidColorBrush(Color.FromRgb(0xDC, 0xE4, 0xEF)), + BorderThickness = new Thickness(1), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }; + + var content = new StackPanel(); + content.Children.Add(new TextBlock + { + Text = "FAT WORKSPACE", + Style = TryFindResource("MicroLabel") as Style, + Foreground = TryFindResource("Accent") as Brush, + HorizontalAlignment = HorizontalAlignment.Center + }); + content.Children.Add(new TextBlock + { + Text = "Production FAT inside Engineering", + Margin = new Thickness(0, 7, 0, 0), + FontSize = 22, + FontWeight = FontWeights.SemiBold, + Foreground = TryFindResource("Ink") as Brush ?? Brushes.Black, + HorizontalAlignment = HorizontalAlignment.Center + }); + content.Children.Add(new TextBlock + { + Text = "Open an SCL FAT project to use the proven automatic capture / completion workflow. The global IED Explorer stays visible at left and the shared Command Dock remains the only command surface.", + Margin = new Thickness(0, 10, 0, 18), + MaxWidth = 610, + TextAlignment = TextAlignment.Center, + TextWrapping = TextWrapping.Wrap, + FontSize = 12.2, + Foreground = TryFindResource("Muted") as Brush ?? Brushes.DimGray + }); + + var actions = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Center + }; + var openScl = new Button + { + Content = "Open SCL for FAT", + Style = TryFindResource("PrimaryButton") as Style, + Padding = new Thickness(14, 8, 14, 8), + Margin = new Thickness(0, 0, 8, 0) + }; + openScl.Click += OpenSclFatTesting_Click; + actions.Children.Add(openScl); + + var openProject = new Button + { + Content = "Open ARSAS Project", + Style = TryFindResource("SoftButton") as Style, + Padding = new Thickness(14, 8, 14, 8) + }; + openProject.Click += OpenIoListPackage_Click; + actions.Children.Add(openProject); + content.Children.Add(actions); + + content.Children.Add(new TextBlock + { + Text = "Excel workflow is intentionally not part of this primary FAT tab.", + Margin = new Thickness(0, 14, 0, 0), + FontSize = 10.6, + Foreground = TryFindResource("Muted") as Brush ?? Brushes.DimGray, + HorizontalAlignment = HorizontalAlignment.Center + }); + + card.Child = content; + root.Children.Add(card); + return root; + } + + private void ProductionFatNavButton_Click(object sender, RoutedEventArgs e) + { + if (MainTabs.Items.Count <= NativeFatWorkspaceIndex) + return; + + MainTabs.SelectedIndex = NativeFatWorkspaceIndex; + QueueNativeFatNavigationGeometry(); + SynchronizeProductionFatSelectedIed(); + _productionFatWindow?.NotifyEmbeddedHostActivated(); + } + + private void ProductionFat_MainTabsSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (!ReferenceEquals(e.Source, MainTabs)) + return; + + QueueNativeFatNavigationGeometry(); + if (MainTabs.SelectedIndex == NativeFatWorkspaceIndex) + { + SynchronizeProductionFatSelectedIed(); + _productionFatWindow?.NotifyEmbeddedHostActivated(); + } + else + { + _productionFatWindow?.Storage?.ScheduleSave(); + } + } + + private void ProductionFat_MainWindowPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(SelectedDevice)) + SynchronizeProductionFatSelectedIed(); + } + + private void SynchronizeProductionFatSelectedIed() + => _productionFatWindow?.SelectEngineeringDeviceForEmbeddedFat(SelectedDevice); + + internal bool MountProductionFatWorkspace(IoListTestingWindow window, FrameworkElement surface) + { + ArgumentNullException.ThrowIfNull(window); + ArgumentNullException.ThrowIfNull(surface); + if (!ProductionFatTabReady || _nativeFatTab == null) + return false; + + _productionFatWindow = window; + _productionFatSurface = surface; + surface.DataContext = window; + _nativeFatTab.Content = surface; + _persistentWorkbench?.DockExpandedByWorkspace[NativeFatWorkspaceIndex] = true; + + window.Closed -= ProductionFatWindow_Closed; + window.Closed += ProductionFatWindow_Closed; + SynchronizeProductionFatSelectedIed(); + + MainTabs.SelectedIndex = NativeFatWorkspaceIndex; + QueueNativeFatNavigationGeometry(); + + // The legacy launcher hides Engineering before showing IoListTestingWindow. + // Once its proven central workspace is re-parented here, restore Engineering and + // keep the legacy Window loaded-but-hidden solely as the production controller owner. + IsEnabled = true; + if (!IsVisible) + Show(); + if (WindowState == WindowState.Minimized) + WindowState = WindowState.Normal; + Activate(); + + SetStatus($"FAT ready in Engineering tab · {window.Project.Ieds.Count} IED · production auto-capture workflow."); + return true; + } + + internal void UnmountProductionFatWorkspace(IoListTestingWindow window) + { + if (!ReferenceEquals(_productionFatWindow, window)) + return; + + window.Closed -= ProductionFatWindow_Closed; + _productionFatWindow = null; + _productionFatSurface = null; + if (_nativeFatTab != null) + _nativeFatTab.Content = BuildProductionFatLauncher(); + } + + private void ProductionFatWindow_Closed(object? sender, EventArgs e) + { + if (sender is IoListTestingWindow window) + UnmountProductionFatWorkspace(window); + } + + private void ProductionFat_MainWindowClosed(object? sender, EventArgs e) + { + PropertyChanged -= ProductionFat_MainWindowPropertyChanged; + MainTabs.SelectionChanged -= ProductionFat_MainTabsSelectionChanged; + Closed -= ProductionFat_MainWindowClosed; + _productionFatInstallRetry?.Stop(); + _productionFatInstallRetry = null; + _productionFatWindow = null; + _productionFatSurface = null; + } +} From 4272309805ceaf248eaea848d1f1c1ddbbf81c4e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 18:49:55 +0700 Subject: [PATCH 22/33] Embed production FAT workspace in Engineering tab --- ...stTestingWindow.EmbeddedEngineeringHost.cs | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 IoListTestingWindow.EmbeddedEngineeringHost.cs diff --git a/IoListTestingWindow.EmbeddedEngineeringHost.cs b/IoListTestingWindow.EmbeddedEngineeringHost.cs new file mode 100644 index 000000000..5e434facc --- /dev/null +++ b/IoListTestingWindow.EmbeddedEngineeringHost.cs @@ -0,0 +1,240 @@ +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +/// +/// Hosts the proven IoListTestingWindow production workspace inside MainWindow's FAT tab. +/// The production Window remains loaded-but-hidden so its existing session controller, +/// auto-capture lifecycle, persistence and report-preview code stay the single FAT authority. +/// Only its central workspace + FAT status footer are re-parented into Engineering. +/// +public partial class IoListTestingWindow +{ + private bool _engineeringEmbeddedMountQueued; + private bool _engineeringEmbeddedMounted; + private FrameworkElement? _engineeringEmbeddedSurface; + + [ModuleInitializer] + internal static void RegisterEmbeddedEngineeringFatHost() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(EmbeddedEngineeringFatHost_Loaded), + handledEventsToo: true); + } + + private static void EmbeddedEngineeringFatHost_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not IoListTestingWindow window || + !ReferenceEquals(e.OriginalSource, window) || + window._engineeringEmbeddedMounted || + window._engineeringEmbeddedMountQueued || + window.Owner is not MainWindow owner || + !owner.ProductionFatTabReady) + { + return; + } + + window._engineeringEmbeddedMountQueued = true; + + // MainWindow's legacy launcher still calls Show() on this Window. Make that bootstrap + // surface invisible immediately; the actual production visual is moved into Engineering + // on the next Loaded-priority dispatcher turn, before ContextIdle command-panel work. + window.ShowActivated = false; + window.ShowInTaskbar = false; + window.Opacity = 0d; + + window.Dispatcher.BeginInvoke( + DispatcherPriority.Loaded, + new Action(() => window.TryMountIntoEngineering(owner))); + } + + private void TryMountIntoEngineering(MainWindow owner) + { + _engineeringEmbeddedMountQueued = false; + if (_engineeringEmbeddedMounted || !IsLoaded || !ReferenceEquals(Owner, owner) || !owner.ProductionFatTabReady) + return; + + try + { + EnsureProductionFatPresentationForEmbeddedHost(); + DisableLegacyEmbeddedCommandPanel(); + var surface = DetachProductionFatCentralWorkspace(); + if (surface == null) + return; + + _engineeringEmbeddedSurface = surface; + _engineeringEmbeddedMounted = owner.MountProductionFatWorkspace(this, surface); + if (!_engineeringEmbeddedMounted) + return; + + // The central FAT view now belongs to MainWindow. Keep this Window loaded and + // hidden because existing controller/session/event code is intentionally reused. + Hide(); + } + catch (Exception ex) + { + Opacity = 1d; + ShowInTaskbar = true; + ShowActivated = true; + MessageBox.Show( + owner, + $"ARSAS could not embed the production FAT workspace. The standalone FAT window will remain available.\n\n{ex.Message}", + "FAT workspace host", + MessageBoxButton.OK, + MessageBoxImage.Warning); + } + } + + private void EnsureProductionFatPresentationForEmbeddedHost() + { + // P1 normally installs this during Loaded. Calling it explicitly is safe/idempotent + // and guarantees the first embedded frame is the same V2 FAT grid as the old Window. + InstallFatV2WorkspaceUx(); + + // PrintPreview historically installs from OnContentRendered. The bootstrap Window is + // intentionally transparent, so install the exact same production preview explicitly + // before the central workspace is detached. This preserves full-center mode switching. + if (!_printPreviewInstalled) + { + InstallPerIedPrintPreview(); + PropertyChanged += PrintPreviewWindow_PropertyChanged; + Session.PropertyChanged += PrintPreviewSession_PropertyChanged; + Closed += PrintPreviewWindow_Closed; + _printPreviewInstalled = true; + } + + // The embedded center already declares WorkspacePreviewToggle in XAML. Make that + // visible button the production toggle authority instead of the hidden Window header. + if (WorkspacePreviewToggle != null) + _printPreviewToggle = WorkspacePreviewToggle; + } + + private void DisableLegacyEmbeddedCommandPanel() + { + // Engineering already owns one shared Command Dock. Prevent the old FAT window's + // duplicate command panel from being created/refreshed while embedded. A non-null + // sentinel makes the queued legacy installer return immediately; null row/summary + // references make its queued refresh a no-op. No command runtime semantics change. + DetachFatCommandDevice(); + if (_fatCommandPanelShell?.Parent is Grid existingHost) + { + var row = Grid.GetRow(_fatCommandPanelShell); + existingHost.Children.Remove(_fatCommandPanelShell); + if (row >= 0 && row < existingHost.RowDefinitions.Count) + existingHost.RowDefinitions[row].Height = new GridLength(0); + if (row - 1 >= 0 && row - 1 < existingHost.RowDefinitions.Count) + existingHost.RowDefinitions[row - 1].Height = new GridLength(0); + } + + _fatCommandPanelShell ??= new Border { Visibility = Visibility.Collapsed }; + _fatCommandRows = null; + _fatCommandSummary = null; + _fatCommandEmptyState = null; + } + + private FrameworkElement? DetachProductionFatCentralWorkspace() + { + if (Content is not Grid root) + return null; + + var middle = root.Children + .OfType() + .FirstOrDefault(child => Grid.GetRow(child) == 2); + var workspaceBorder = middle?.Children + .OfType() + .FirstOrDefault(child => Grid.GetColumn(child) == 2); + if (middle == null || workspaceBorder == null) + return null; + + var footer = root.Children + .OfType() + .FirstOrDefault(child => Grid.GetRow(child) == 4); + + middle.Children.Remove(workspaceBorder); + if (footer != null) + root.Children.Remove(footer); + + var host = new Grid + { + DataContext = this, + Margin = new Thickness(0) + }; + host.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + if (footer != null) + { + host.RowDefinitions.Add(new RowDefinition { Height = new GridLength(8) }); + host.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + } + + workspaceBorder.Margin = new Thickness(0); + Grid.SetRow(workspaceBorder, 0); + Grid.SetColumn(workspaceBorder, 0); + host.Children.Add(workspaceBorder); + + if (footer != null) + { + footer.Margin = new Thickness(0); + Grid.SetRow(footer, 2); + Grid.SetColumn(footer, 0); + host.Children.Add(footer); + } + + return host; + } + + internal void SelectEngineeringDeviceForEmbeddedFat(Iec61850MonitorDevice? device) + { + if (!_engineeringEmbeddedMounted || device == null) + return; + + var match = Project.Ieds.FirstOrDefault(ied => + (!string.IsNullOrWhiteSpace(ied.LiveDeviceId) && + ied.LiveDeviceId.Equals(device.DeviceId, StringComparison.OrdinalIgnoreCase)) || + (!string.IsNullOrWhiteSpace(ied.IpAddress) && + ied.IpAddress.Equals(device.IpAddress, StringComparison.OrdinalIgnoreCase)) || + ied.IedName.Equals(device.SclIedName, StringComparison.OrdinalIgnoreCase) || + ied.IedName.Equals(device.Name, StringComparison.OrdinalIgnoreCase)); + if (match == null || ReferenceEquals(SelectedIed, match)) + return; + + // Do not retarget an active production FAT transaction/session. When idle, the + // persistent Engineering IED Explorer is the navigation/selection authority. + if (!CanSelectIed) + return; + + SelectedIed = match; + } + + internal void NotifyEmbeddedHostActivated() + { + if (!_engineeringEmbeddedMounted) + return; + + RefreshFatV2WorkspaceUx(refreshRows: true); + if (_printPreviewActive) + RefreshPrintPreview(); + } + + private void EmbeddedEngineeringFatHost_Closed(object? sender, EventArgs e) + { + if (Owner is MainWindow owner) + owner.UnmountProductionFatWorkspace(this); + Closed -= EmbeddedEngineeringFatHost_Closed; + _engineeringEmbeddedMounted = false; + _engineeringEmbeddedSurface = null; + } + + // Field initializer cannot attach an instance event. Hook cleanup once the embedded + // surface has actually been mounted; this helper is called from the production owner. + internal void RegisterEmbeddedHostCloseCleanup() + { + Closed -= EmbeddedEngineeringFatHost_Closed; + Closed += EmbeddedEngineeringFatHost_Closed; + } +} From 624b4fe91ff49194c33845a777658fe7d8195deb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 18:50:44 +0700 Subject: [PATCH 23/33] Fix production FAT dock state assignment --- MainWindow.ProductionFatTab.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/MainWindow.ProductionFatTab.cs b/MainWindow.ProductionFatTab.cs index 76aa55785..b0d01d9e3 100644 --- a/MainWindow.ProductionFatTab.cs +++ b/MainWindow.ProductionFatTab.cs @@ -226,7 +226,8 @@ internal bool MountProductionFatWorkspace(IoListTestingWindow window, FrameworkE _productionFatSurface = surface; surface.DataContext = window; _nativeFatTab.Content = surface; - _persistentWorkbench?.DockExpandedByWorkspace[NativeFatWorkspaceIndex] = true; + if (_persistentWorkbench != null) + _persistentWorkbench.DockExpandedByWorkspace[NativeFatWorkspaceIndex] = true; window.Closed -= ProductionFatWindow_Closed; window.Closed += ProductionFatWindow_Closed; From 11b6c8b78c957f452ffb5db935045099a4d67a9b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 20:37:04 +0700 Subject: [PATCH 24/33] FAT: project Engineering static DataSet into production workspace --- ...atEngineeringWorkspaceProjectionService.cs | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 Services/IoTesting/IoFatEngineeringWorkspaceProjectionService.cs diff --git a/Services/IoTesting/IoFatEngineeringWorkspaceProjectionService.cs b/Services/IoTesting/IoFatEngineeringWorkspaceProjectionService.cs new file mode 100644 index 000000000..d6c9bc89c --- /dev/null +++ b/Services/IoTesting/IoFatEngineeringWorkspaceProjectionService.cs @@ -0,0 +1,219 @@ +using AR.Iec61850.Scl.Workspace; +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +public sealed record IoFatEngineeringWorkspaceProjection( + IoTestProject Project, + IReadOnlyList SourceInputs, + IReadOnlyList RuntimeWorkspaces); + +/// +/// Builds the production FAT project directly from the already-parsed Engineering SCL +/// workspaces. This is deliberately not an SCL importer: it never opens/parses XML and it +/// never creates a second IEC 61850 model. Engineering remains the static DataSet/live-value +/// authority; FAT adds only its production evidence/session lifecycle on top. +/// +public static class IoFatEngineeringWorkspaceProjectionService +{ + public static async Task BuildAsync( + IReadOnlyCollection devices, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(devices); + + var usable = devices + .Where(device => device.SclWorkspace != null) + .Where(device => device.SclWorkspace!.DesignModel.DataSets.Sum(dataSet => dataSet.Members.Count) > 0) + .Where(device => !string.IsNullOrWhiteSpace(device.SclSourcePath)) + .GroupBy(device => device.DeviceId, StringComparer.OrdinalIgnoreCase) + .Select(group => group.Last()) + .ToArray(); + if (usable.Length == 0) + { + throw new InvalidDataException( + "No Engineering IED has an already-parsed SCL workspace with static DataSet members and source provenance."); + } + + var sourceInputs = usable + .Select(device => Path.GetFullPath(device.SclSourcePath)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(path => new IoFatSourceInput(path, IoFatSourceKinds.Scl)) + .ToArray(); + var described = await IoFatSourceWorkspaceService.DescribeAsync(sourceInputs, cancellationToken) + .ConfigureAwait(false); + var descriptorByPath = described.ToDictionary( + item => Path.GetFullPath(item.OriginalPath), + item => item.Source, + StringComparer.OrdinalIgnoreCase); + + foreach (var device in usable) + { + cancellationToken.ThrowIfCancellationRequested(); + var path = Path.GetFullPath(device.SclSourcePath); + if (!descriptorByPath.TryGetValue(path, out var descriptor)) + throw new InvalidDataException($"Engineering SCL provenance is unavailable for '{device.Name}'."); + if (!string.IsNullOrWhiteSpace(device.SclSourceSha256) && + !descriptor.Sha256.Equals(device.SclSourceSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException( + $"The Engineering SCL source for '{device.Name}' changed on disk after it was parsed. FAT will not bind a stale in-memory model to different source bytes."); + } + } + + var workspaceSources = usable + .Select(device => + { + var descriptor = descriptorByPath[Path.GetFullPath(device.SclSourcePath)]; + return new FatSclWorkspaceSource( + descriptor.FileName, + descriptor.Sha256, + device.SclWorkspace!); + }) + .ToArray(); + var verification = FatSclWorkspaceImportService.Import(workspaceSources).Project; + + var deviceByWorkspace = usable + .GroupBy(device => WorkspaceIdentity(device.SclWorkspace!), StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + var descriptorByWorkspace = usable + .GroupBy(device => WorkspaceIdentity(device.SclWorkspace!), StringComparer.OrdinalIgnoreCase) + .ToDictionary( + group => group.Key, + group => descriptorByPath[Path.GetFullPath(group.First().SclSourcePath)], + StringComparer.OrdinalIgnoreCase); + + var plans = new List(); + foreach (var workspaceGroup in workspaceSources + .GroupBy(source => WorkspaceIdentity(source.Workspace), StringComparer.OrdinalIgnoreCase) + .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)) + { + var workspace = workspaceGroup.First().Workspace; + var key = WorkspaceIdentity(workspace); + var device = deviceByWorkspace[key]; + var descriptor = descriptorByWorkspace[key]; + var signals = verification.Signals + .Where(signal => WorkspaceIdentity(signal.IedName, signal.AccessPointName) + .Equals(key, StringComparison.OrdinalIgnoreCase)) + .OrderBy(signal => signal.DataSetReference, StringComparer.OrdinalIgnoreCase) + .ThenBy(signal => signal.DataSetMemberIndex) + .ToArray(); + + var endpoint = !string.IsNullOrWhiteSpace(device.IpAddress) + ? device.IpAddress + : workspace.PreferredEndpoint?.HasUsableAddress == true + ? workspace.PreferredEndpoint.IpAddress + : string.Empty; + var plan = new IoTestIedPlan + { + IedName = workspace.IedName, + IpAddress = endpoint, + IedRole = FirstNonEmpty(workspace.IedType, workspace.Manufacturer), + TestPoints = signals.Select(signal => ToPointPlan(signal, descriptor, workspace, endpoint)).ToList() + }; + plan.ApplyLiveDeviceBinding( + device.DeviceId, + device.IsMonitoring ? "Engineering acquisition active" : device.IsConnected ? "Engineering association ready" : "Engineering SCL model ready", + device.IsConnected, + device.IsMonitoring); + plans.Add(plan); + } + + var sourceDescriptors = described + .Select(item => item.Source) + .GroupBy(source => source.SourceId, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .OrderBy(source => source.SourceId, StringComparer.Ordinal) + .ToArray(); + var sourceFingerprint = IoFatSourceIdentity.ComputeSetFingerprint(sourceDescriptors); + var project = new IoTestProject + { + ProjectId = "FAT-SCL-" + sourceFingerprint[..16], + SchemaVersion = "ARSAS-FAT-SCL-1.0", + ProjectName = sourceDescriptors.Length == 1 + ? Path.GetFileNameWithoutExtension(sourceDescriptors[0].FileName) + " FAT" + : $"IEC 61850 SCL FAT ({sourceDescriptors.Length} sources)", + DocumentControl = new IoFatDocumentControl + { + DocumentTitle = "IEC 61850 FAT", + SourceDocumentName = string.Join("; ", sourceDescriptors.Select(source => source.FileName)) + }, + Ieds = plans + }; + project.SetSources(sourceDescriptors, sourceFingerprint); + project.InitializeRuntimeNotifications(); + + var staticMemberCount = usable.Sum(device => + device.SclWorkspace!.DesignModel.DataSets.Sum(dataSet => dataSet.Members.Count)); + if (project.SignalCount != verification.Signals.Count || project.SignalCount != staticMemberCount) + { + throw new InvalidDataException( + $"Engineering FAT projection produced {project.SignalCount} row(s), but the authoritative static DataSet scope contains {staticMemberCount}. FAT refuses a partial projection."); + } + + return new IoFatEngineeringWorkspaceProjection( + project, + sourceInputs, + usable.Select(device => device.SclWorkspace!).ToArray()); + } + + private static IoTestPointPlan ToPointPlan( + FatVerificationSignal signal, + IoFatSourceDescriptor source, + SclIedWorkspace workspace, + string endpoint) + { + var discrete = signal.SignalKind == FatSignalKind.Discrete; + return new IoTestPointPlan + { + TestPointId = $"scl-{source.SourceId}-{signal.SignalId}", + IedName = signal.IedName, + IpAddress = endpoint, + SignalName = signal.SignalName, + ObjectReference = FirstNonEmpty(signal.RuntimeReference, signal.StaticMemberReference), + FunctionalConstraint = signal.FunctionalConstraint, + ExpectedOnText = discrete ? "TRUE" : "Value 1", + ExpectedOffText = discrete ? "FALSE" : "Value 2", + ExpectedOnRaw = 1, + ExpectedOffRaw = 0, + DataType = signal.DataType, + SignalAddress = source.SourceId, + DataSetName = signal.DataSetReference, + SourceIecReference = signal.StaticMemberReference, + ReportDisplayReference = signal.StaticMemberReference, + EventLogSearchReference = signal.StaticMemberReference, + EvidenceExpected = signal.CaptureMode == FatCaptureMode.AutomaticTransition + ? "Automatic Value 1 / Value 2 transition capture" + : "Operator Value 1 / Value 2 snapshot capture", + SourceSheet = source.FileName, + SourceRow = signal.DataSetMemberIndex + 1, + SignalKind = signal.SignalKind, + CaptureMode = signal.CaptureMode, + TestEnabled = true, + ImportReady = true, + BindingStatus = "ENGINEERING_SCL_DATASET_AUTHORITY", + BindingEvidence = string.Join(" • ", new[] + { + "shared Engineering ARIEC static DataSet authority", + $"sourceId={source.SourceId}", + $"sourceSha256={source.Sha256}", + $"workspace={workspace.WorkspaceKey}", + $"dataset={signal.DataSetReference}", + $"memberIndex={signal.DataSetMemberIndex}", + $"static={signal.StaticMemberReference}", + $"kind={signal.SignalKind}", + $"capture={signal.CaptureMode}" + }) + }; + } + + private static string WorkspaceIdentity(SclIedWorkspace workspace) + => WorkspaceIdentity(workspace.IedName, workspace.AccessPointName); + + private static string WorkspaceIdentity(string? iedName, string? accessPointName) + => $"{(iedName ?? string.Empty).Trim()}|{(accessPointName ?? string.Empty).Trim()}"; + + private static string FirstNonEmpty(params string?[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; +} From cdc9052f3260f2ff8abc331e668d08800743cbec Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 20:38:07 +0700 Subject: [PATCH 25/33] FAT: adopt Engineering-owned SCL runtime workspaces --- .../IoTesting/IoFatSclProjectImportService.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Services/IoTesting/IoFatSclProjectImportService.cs b/Services/IoTesting/IoFatSclProjectImportService.cs index 398915baf..da208b075 100644 --- a/Services/IoTesting/IoFatSclProjectImportService.cs +++ b/Services/IoTesting/IoFatSclProjectImportService.cs @@ -67,6 +67,22 @@ internal bool TryGetRuntimeWorkspace( } } + /// + /// Registers ARIEC workspaces that are already owned by Engineering. This is the + /// zero-reparse bridge used by the embedded FAT tab: the exact SclIedWorkspace objects + /// already attached to Explorer devices become the production FAT runtime authority. + /// + internal void AdoptEngineeringRuntimeWorkspaces(IEnumerable workspaces) + { + ArgumentNullException.ThrowIfNull(workspaces); + var stable = workspaces + .Where(workspace => workspace != null) + .GroupBy(workspace => workspace.WorkspaceKey, StringComparer.OrdinalIgnoreCase) + .Select(group => group.Last()) + .ToArray(); + SetRuntimeWorkspaces(stable); + } + public Task ImportAsync( IReadOnlyCollection sclPaths, string? projectName = null, From c12c4c31163acbc728b9625bf6caab325560fce0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 20:38:57 +0700 Subject: [PATCH 26/33] FAT: auto bootstrap from Engineering static DataSet --- ...indow.ProductionFatEngineeringBootstrap.cs | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 MainWindow.ProductionFatEngineeringBootstrap.cs diff --git a/MainWindow.ProductionFatEngineeringBootstrap.cs b/MainWindow.ProductionFatEngineeringBootstrap.cs new file mode 100644 index 000000000..c077f7211 --- /dev/null +++ b/MainWindow.ProductionFatEngineeringBootstrap.cs @@ -0,0 +1,177 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; +using ArIED61850Tester.Services.IoTesting; + +namespace ArIED61850Tester; + +/// +/// Makes the embedded production FAT workspace a projection of the Engineering workspace. +/// Opening SCL in Explorer is therefore sufficient: selecting FAT reuses the already-parsed +/// ARIEC SCL/static DataSet authority and the existing Engineering acquisition session. +/// +public partial class MainWindow +{ + private bool _productionFatEngineeringBootstrapInstalled; + private bool _productionFatEngineeringBootstrapBusy; + private CancellationTokenSource? _productionFatEngineeringBootstrapCts; + + [ModuleInitializer] + internal static void RegisterProductionFatEngineeringBootstrap() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(ProductionFatEngineeringBootstrap_Loaded), + handledEventsToo: true); + } + + private static void ProductionFatEngineeringBootstrap_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not MainWindow window || window._productionFatEngineeringBootstrapInstalled) + return; + + window._productionFatEngineeringBootstrapInstalled = true; + window.MainTabs.SelectionChanged += window.ProductionFatEngineeringBootstrap_SelectionChanged; + window.PropertyChanged += window.ProductionFatEngineeringBootstrap_PropertyChanged; + window.Closed += window.ProductionFatEngineeringBootstrap_Closed; + window.QueueProductionFatEngineeringBootstrap(); + } + + private void ProductionFatEngineeringBootstrap_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (!ReferenceEquals(e.Source, MainTabs) || MainTabs.SelectedIndex != NativeFatWorkspaceIndex) + return; + QueueProductionFatEngineeringBootstrap(); + } + + private void ProductionFatEngineeringBootstrap_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(SelectedDevice) || MainTabs.SelectedIndex != NativeFatWorkspaceIndex) + return; + + // A running production FAT session owns its latched IED. The normal embedded-host + // selection bridge handles idle retargeting. Bootstrap is only needed before a FAT + // workspace exists. + if (_productionFatWindow == null && _loadedIoFatWindow == null) + QueueProductionFatEngineeringBootstrap(); + } + + private void QueueProductionFatEngineeringBootstrap() + { + if (!_productionFatEngineeringBootstrapInstalled || MainTabs.SelectedIndex != NativeFatWorkspaceIndex) + return; + + Dispatcher.BeginInvoke( + DispatcherPriority.ContextIdle, + new Action(async () => await EnsureProductionFatFromEngineeringAsync())); + } + + private async Task EnsureProductionFatFromEngineeringAsync() + { + if (_productionFatEngineeringBootstrapBusy || + MainTabs.SelectedIndex != NativeFatWorkspaceIndex || + !ProductionFatTabReady) + { + return; + } + + if (_productionFatWindow is { IsLoaded: true } || _loadedIoFatWindow is { IsLoaded: true }) + { + SynchronizeProductionFatSelectedIed(); + return; + } + + var selected = SelectedDevice; + if (selected?.SclWorkspace == null || + selected.SclWorkspace.DesignModel.DataSets.Sum(dataSet => dataSet.Members.Count) == 0) + { + SetStatus(selected == null + ? "FAT · select an Engineering IED with a static DataSet." + : $"FAT · {selected.Name} has no static DataSet scope in the Engineering SCL model."); + return; + } + + var engineeringDevices = Devices + .Where(device => device.SclWorkspace != null) + .Where(device => device.SclWorkspace!.DesignModel.DataSets.Sum(dataSet => dataSet.Members.Count) > 0) + .Where(device => !string.IsNullOrWhiteSpace(device.SclSourcePath)) + .ToArray(); + if (engineeringDevices.All(device => !ReferenceEquals(device, selected))) + { + SetStatus($"FAT · Engineering source provenance for {selected.Name} is unavailable; use Open SCL to restore the source authority."); + return; + } + + _productionFatEngineeringBootstrapBusy = true; + _productionFatEngineeringBootstrapCts?.Cancel(); + _productionFatEngineeringBootstrapCts?.Dispose(); + _productionFatEngineeringBootstrapCts = CancellationTokenSource.CreateLinkedTokenSource(_applicationCancellation.Token); + var token = _productionFatEngineeringBootstrapCts.Token; + SetStatus($"FAT · preparing {selected.Name} from the Engineering static DataSet…"); + + try + { + var projection = await IoFatEngineeringWorkspaceProjectionService.BuildAsync( + engineeringDevices, + token); + token.ThrowIfCancellationRequested(); + + // Register the exact same ARIEC workspace instances already owned by Explorer. + // Production FAT preparation can therefore prove shared SCL authority without + // reparsing XML or starting a second model/acquisition stack. + _ioFatSclProjectImportService.AdoptEngineeringRuntimeWorkspaces(projection.RuntimeWorkspaces); + + var launch = await IoTestWorkspaceBootstrapService.OpenSourcesAsync( + projection.Project, + projection.SourceInputs, + IoTestingProjectsRoot(), + IoTestingEvidenceRoot(), + CreateIoTestSession, + token); + token.ThrowIfCancellationRequested(); + + SynchronizeImportedSclFatWithEngineering(launch.Project); + RegisterSharedSclSourcePaths(launch.Project, launch.Project.Ieds, projection.SourceInputs); + foreach (var ied in launch.Project.Ieds) + { + var device = ResolveIoTestDevice(ied.LiveDeviceId) + ?? ResolveIoTestDevice(ied.IpAddress) + ?? ResolveIoTestDevice(ied.IedName); + if (device is not null) + MarkSharedSelectionAuthority(device); + } + + launch.Workspace.ScheduleSave(); + await ShowIoTestingWorkspaceAsync(launch, importWarningCount: 0); + SynchronizeProductionFatSelectedIed(); + SetStatus($"FAT ready · {selected.Name} · Engineering static DataSet authority reused · no SCL re-import."); + } + catch (OperationCanceledException) + { + // Fast navigation/close is normal. No modal interruption is appropriate here. + } + catch (Exception ex) when (ex is IOException or JsonException or InvalidDataException or UnauthorizedAccessException or ArgumentException or InvalidOperationException) + { + AddLog("WARN", "FAT", $"Automatic Engineering FAT bootstrap unavailable: {ex.Message}"); + SetStatus($"FAT · could not reuse the Engineering static DataSet automatically: {ex.Message}"); + } + finally + { + _productionFatEngineeringBootstrapBusy = false; + } + } + + private void ProductionFatEngineeringBootstrap_Closed(object? sender, EventArgs e) + { + MainTabs.SelectionChanged -= ProductionFatEngineeringBootstrap_SelectionChanged; + PropertyChanged -= ProductionFatEngineeringBootstrap_PropertyChanged; + Closed -= ProductionFatEngineeringBootstrap_Closed; + _productionFatEngineeringBootstrapCts?.Cancel(); + _productionFatEngineeringBootstrapCts?.Dispose(); + _productionFatEngineeringBootstrapCts = null; + } +} From d8fdb62b5eba6e71f35ba1e71483b5f38cc27c47 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 20:40:06 +0700 Subject: [PATCH 27/33] FAT: align seventh tab with Engineering nav language --- MainWindow.ProductionFatNavigationParity.cs | 153 ++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 MainWindow.ProductionFatNavigationParity.cs diff --git a/MainWindow.ProductionFatNavigationParity.cs b/MainWindow.ProductionFatNavigationParity.cs new file mode 100644 index 000000000..568dbe727 --- /dev/null +++ b/MainWindow.ProductionFatNavigationParity.cs @@ -0,0 +1,153 @@ +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Media.Animation; +using System.Windows.Threading; + +namespace ArIED61850Tester; + +/// +/// Extends the existing six-destination responsive nav contract to the dynamically-added +/// production FAT destination. The legacy MainWindow navigation code clamps index 6 to 5; +/// this late correction keeps Diagnostics from remaining highlighted while FAT is active +/// and moves the same selection pill into the seventh equal-width cell. +/// +internal static class MainWindowProductionFatNavigationParity +{ + [ModuleInitializer] + internal static void Register() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(OnLoaded), + handledEventsToo: true); + EventManager.RegisterClassHandler( + typeof(MainWindow), + Button.ClickEvent, + new RoutedEventHandler(OnButtonClick), + handledEventsToo: true); + } + + private static void OnLoaded(object sender, RoutedEventArgs e) + { + if (sender is not MainWindow window) + return; + + window.SizeChanged -= Window_SizeChanged; + window.SizeChanged += Window_SizeChanged; + if (window.FindName("MainTabs") is TabControl tabs) + { + tabs.SelectionChanged -= Tabs_SelectionChanged; + tabs.SelectionChanged += Tabs_SelectionChanged; + } + Queue(window, animate: false); + } + + private static void OnButtonClick(object sender, RoutedEventArgs e) + { + if (sender is not MainWindow window || e.Source is not Button button) + return; + if (button.Tag is not int index || index is < 0 or > 6) + return; + Queue(window, animate: true); + } + + private static void Tabs_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (sender is not TabControl tabs || !ReferenceEquals(e.Source, tabs) || + Window.GetWindow(tabs) is not MainWindow window) + { + return; + } + Queue(window, animate: true); + } + + private static void Window_SizeChanged(object sender, SizeChangedEventArgs e) + { + if (sender is MainWindow window) + Queue(window, animate: false); + } + + private static void Queue(MainWindow window, bool animate) + { + // Existing MainWindow + responsive-layout handlers still perform their historical + // six-slot correction. ApplicationIdle deliberately runs after those handlers so + // the seven-slot Engineering shell is the final visual authority. + window.Dispatcher.BeginInvoke( + DispatcherPriority.ApplicationIdle, + new Action(() => Apply(window, animate))); + } + + private static void Apply(MainWindow window, bool animate) + { + if (window.FindName("MainTabs") is not TabControl tabs || tabs.Items.Count < 7 || + window.FindName("WorkflowNavGrid") is not Grid grid || + window.FindName("WorkflowNavShell") is not Border shell || + window.FindName("WorkflowPill") is not Border pill) + { + return; + } + + var buttons = grid.Children + .OfType