From 40927cdf42e8bd6082324b92fb7acb92ff3802f5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 18:54:21 +0700 Subject: [PATCH 01/39] ux(write): carry verified compact typed legacy editor onto newest integrated FAT base --- BrowserDataWriteWindow.xaml | 168 ++++++++++++++++++++++-------------- 1 file changed, 104 insertions(+), 64 deletions(-) diff --git a/BrowserDataWriteWindow.xaml b/BrowserDataWriteWindow.xaml index 63664c816..eadeff6d7 100644 --- a/BrowserDataWriteWindow.xaml +++ b/BrowserDataWriteWindow.xaml @@ -1,85 +1,125 @@ - + + + + + + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + + + + + + + + + - + - - + From d2f2b5c547072a23b624dacc1ddfc7f39076f48f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 18:54:29 +0700 Subject: [PATCH 02/39] ux(write): carry verified compact typed legacy editor onto newest integrated FAT base --- BrowserDataWriteWindow.xaml.cs | 220 ++++++++++++++++++++++++++++++++- 1 file changed, 219 insertions(+), 1 deletion(-) diff --git a/BrowserDataWriteWindow.xaml.cs b/BrowserDataWriteWindow.xaml.cs index 5a326d4ed..0b4a8978b 100644 --- a/BrowserDataWriteWindow.xaml.cs +++ b/BrowserDataWriteWindow.xaml.cs @@ -31,7 +31,225 @@ public BrowserDataWriteWindow( ArgumentNullException.ThrowIfNull(attribute); _reference = MmsObjectReference.Parse( attribute.MmsReference, attribute.FunctionalConstraint); - ReferenceLabel.Text = $"{_device.Name} • {_reference.Domain}/{_reference.Item} [{_reference.FunctionalConstraint}]"; + ReferenceLabel.Text = $"{_device.Name} · {_reference.Domain}/{_reference.Item} [{_reference.FunctionalConstraint}]"; + AttributeLabel.Text = _reference.Item.Split((char)36).Last() + + " [" + _reference.FunctionalConstraint + "]"; + } + + private async void Window_Loaded(object sender, RoutedEventArgs e) + => await ReadValueAsync(); + + private async void Read_Click(object sender, RoutedEventArgs e) + { + if (!_busy) await ReadValueAsync(); + } + + private async Task ReadValueAsync() + { + _busy = true; + _preview = null; + WriteButton.IsEnabled = false; + ReadButton.IsEnabled = false; + NewValueBox.IsEnabled = false; + BooleanPicker.IsEnabled = false; + NewValueBox.Visibility = Visibility.Collapsed; + BooleanPicker.Visibility = Visibility.Collapsed; + UnsupportedLabel.Visibility = Visibility.Visible; + UnsupportedLabel.Text = "Reading IED…"; + try + { + _preview = await _runtime.PreviewBrowserDataAttributeWriteAsync( + _device, _reference, _lifetime.Token); + if (_lifetime.IsCancellationRequested) return; + if (_preview is not { IsAvailable: true, Type: not null, OldValue: not null } p) + { + StatusLabel.Text = _preview?.Message ?? "Live GVA and value not available."; + DiffLabel.Text = "No Write is permitted. Select another live DA."; + return; + } + + CurrentLabel.Text = p.CurrentText; + TypeLabel.Text = p.Type.SclBType.Length > 0 + ? $"{p.Type.SclBType} ({p.Type.MmsType}, {p.Type.Size?.ToString() ?? "?"})" + : $"{p.Type.MmsType} ({p.Type.Size?.ToString() ?? "?"})"; + var supported = p.Type.MmsType switch + { + "boolean" or "integer" or "unsigned" or "visible-string" or "mms-string" => true, + "floating-point" when p.Type.Size == 32 => true, + _ => false + }; + if (supported && p.Type.MmsType == "boolean") + { + BooleanPicker.Visibility = Visibility.Visible; + BooleanPicker.SelectedItem = (p.OldValue.Value is true) ? "true" : "false"; + BooleanPicker.IsEnabled = true; + UnsupportedLabel.Visibility = Visibility.Collapsed; + } + else if (supported) + { + // Prefill from the exact live read. Never present a blind empty box. + // Unsupported types remain read-only (no codec guessing). + NewValueBox.Visibility = Visibility.Visible; + NewValueBox.Text = p.Type.MmsType switch + { + "visible-string" or "mms-string" => p.OldValue.Value as string ?? "", + "integer" or "unsigned" or "floating-point" => + Convert.ToString(p.OldValue.Value, + System.Globalization.CultureInfo.InvariantCulture) ?? "", + _ => "" + }; + NewValueBox.IsEnabled = true; + UnsupportedLabel.Visibility = Visibility.Collapsed; + } + else + { + UnsupportedLabel.Text = "Unsupported live MMS type"; + } + StatusLabel.Text = supported + ? "Select or edit the value, then Write." + : "Unsupported type · no Write permitted"; + DiffLabel.Text = "Current: " + p.CurrentText + " · Review the value before Write."; + if (supported && p.Type.MmsType == "boolean") BooleanPicker.Focus(); + else if (supported) NewValueBox.Focus(); + } + catch (OperationCanceledException) + { + if (!_lifetime.IsCancellationRequested) + StatusLabel.Text = "Preview cancelled; no Write sent."; + } + catch (Exception ex) + { + if (!_lifetime.IsCancellationRequested) + StatusLabel.Text = "Preview refused: " + ex.Message; + } + finally + { + _busy = false; + ReadButton.IsEnabled = true; + RefreshInput(); + } + } + + private void NewValueBox_TextChanged(object sender, TextChangedEventArgs e) + => RefreshInput(); + + private void BooleanPicker_SelectionChanged(object sender, SelectionChangedEventArgs e) + => RefreshInput(); + + private string SelectedInput() => + BooleanPicker.Visibility == Visibility.Visible + ? (BooleanPicker.SelectedItem as string ?? "") + : NewValueBox.Text; + + private void RefreshInput() + { + if (WriteButton is null || DiffLabel is null || NewValueBox is null || + BooleanPicker is null) return; + WriteButton.IsEnabled = false; + if (_busy || _preview is not { IsAvailable: true, Type: not null, OldValue: not null } p || + !(NewValueBox.IsEnabled || BooleanPicker.IsEnabled)) + return; + if (!IedBrowserTypedWriteInput.TryParse(p.Type, SelectedInput(), + out var value, out var error) || value is null) + { + DiffLabel.Text = error; + return; + } + var gva = new MmsVariableAccessAttributesResult + { + IsSuccess = true, Reference = _reference, TypeSpecification = p.Type + }; + var rejection = IedBrowserTypedWriteAdmission.RejectReason( + _reference, gva, p.GvaSignature, value); + if (rejection is not null) + { + DiffLabel.Text = rejection; + return; + } + var oldBytes = MmsDataCodec.Encode(p.OldValue); + var newBytes = MmsDataCodec.Encode(value); + var changed = !oldBytes.AsSpan().SequenceEqual(newBytes); + DiffLabel.Text = $"Current: {p.CurrentText} → New: {MmsDataCodec.ToDisplayString(value)}" + + (changed ? " · Ready to Write" : " · Unchanged"); + WriteButton.IsEnabled = changed; + } + + private async void Write_Click(object sender, RoutedEventArgs e) + { + if (_busy || _preview is not { IsAvailable: true, Type: not null, OldValue: not null } p || + !WriteButton.IsEnabled || + !IedBrowserTypedWriteInput.TryParse(p.Type, SelectedInput(), + out var proposed, out _) || proposed is null) + return; + var gva = new MmsVariableAccessAttributesResult + { + IsSuccess = true, Reference = _reference, TypeSpecification = p.Type + }; + if (IedBrowserTypedWriteAdmission.RejectReason( + _reference, gva, p.GvaSignature, proposed) is not null) + { + RefreshInput(); + return; + } + + var confirm = + "Confirm ONE IEC 61850 MMS data Write on the connected IED?\n\n" + + $"IED: {_device.Name}\n" + + $"DA: {_reference.Domain}/{_reference.Item} [{_reference.FunctionalConstraint}]\n" + + $"Before: {p.CurrentText}\n" + + $"After: {MmsDataCodec.ToDisplayString(proposed)}\n\n" + + "No automatic retries, rollback, or control Operate. " + + "The server may reject Write; readback must independently match."; + if (MessageBox.Show(this, confirm, "Confirm live MMS Write", + MessageBoxButton.YesNo, MessageBoxImage.Warning, + MessageBoxResult.No) != MessageBoxResult.Yes) + return; + + _busy = true; + WriteButton.IsEnabled = false; + NewValueBox.IsEnabled = false; + BooleanPicker.IsEnabled = false; + ReadButton.IsEnabled = false; + StatusLabel.Text = "Writing one exact MMS DA…"; + try + { + var step = new IedBrowserTypedWriteStep( + _reference, p.GvaSignature, p.OldValue, proposed); + var outcome = await _runtime.WriteBrowserDataAttributesAsync( + _device, new[] { step }, true, _lifetime.Token); + if (_lifetime.IsCancellationRequested) return; + var result = outcome.Rows.Single(); + StatusLabel.Text = $"{result.State}: {result.Message}"; + DiffLabel.Text = + "Write completed or resolved as uncertain. Close and inspect the actual IED value " + + "before another attempt; physical process change is NOT implied."; + _preview = null; // A new attempt must acquire a NEW live GVA/read + confirmation. + } + catch (OperationCanceledException) + { + if (!_lifetime.IsCancellationRequested) + StatusLabel.Text = "Write may have been dispatched: outcome UNKNOWN. Read IED manually."; + } + catch (Exception ex) + { + if (!_lifetime.IsCancellationRequested) + StatusLabel.Text = "Write refused or outcome unresolved: " + ex.Message; + } + finally + { + _busy = false; + ReadButton.IsEnabled = true; + WriteButton.IsEnabled = false; + } + } + + private void Close_Click(object sender, RoutedEventArgs e) => Close(); + + private void Window_Closed(object? sender, EventArgs e) => _lifetime.Cancel(); +} +).Last() + + " [" + _reference.FunctionalConstraint + "]"; + BooleanPicker.ItemsSource = new[] { "false", "true" }; } private async void Window_Loaded(object sender, RoutedEventArgs e) From bcc95f8d7b79a38cd9166bff1d9ec647fac576bc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 18:54:35 +0700 Subject: [PATCH 03/39] ux(write): carry verified compact typed legacy editor onto newest integrated FAT base --- BrowserBatchWriteWindow.xaml | 161 +++++++++++++++++++++++------------ 1 file changed, 106 insertions(+), 55 deletions(-) diff --git a/BrowserBatchWriteWindow.xaml b/BrowserBatchWriteWindow.xaml index 3597b69cf..da2a31376 100644 --- a/BrowserBatchWriteWindow.xaml +++ b/BrowserBatchWriteWindow.xaml @@ -1,38 +1,37 @@ - + Background="#F5F7FB" FontFamily="{StaticResource AppFontFamily}" FontSize="11.5" + Icon="Assets/app-icon.ico" Loaded="Window_Loaded" Closed="Window_Closed"> + - - + - - - + + - - + - + - - - - - + + + + + + + + + + + + - + + + + + - + + + + + + - - - - - - - - - - - - - - - + + \ No newline at end of file From 4723ad2b0f5e9b5462ce76316febcf3c8b1c17a7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 18:54:43 +0700 Subject: [PATCH 04/39] ux(write): carry verified compact typed legacy editor onto newest integrated FAT base --- BrowserBatchWriteWindow.xaml.cs | 110 ++++++++++++++++++++++++++++---- 1 file changed, 99 insertions(+), 11 deletions(-) diff --git a/BrowserBatchWriteWindow.xaml.cs b/BrowserBatchWriteWindow.xaml.cs index da9bd4413..ee1c5f58e 100644 --- a/BrowserBatchWriteWindow.xaml.cs +++ b/BrowserBatchWriteWindow.xaml.cs @@ -103,11 +103,42 @@ private void SetBusy(bool busy) WriteButton.IsEnabled = !busy && _prepared; } - private void Window_Loaded(object sender, RoutedEventArgs e) + private async void Window_Loaded(object sender, RoutedEventArgs e) { var work = SystemParameters.WorkArea; - Height = Math.Max(MinHeight, Math.Min(580, work.Height - 26)); - Width = Math.Max(MinWidth, Math.Min(760, work.Width - 26)); + Height = Math.Max(MinHeight, Math.Min(515, work.Height - 26)); + Width = Math.Max(MinWidth, Math.Min(940, work.Width - 26)); + await PrimeTypedRowsAsync(); + } + + // The first bounded visible batch is read-only hydrated on open, like + // IEDScout. No blank "new value" textbox and NO hidden Write/Operate. + // Larger DOs can use Read on an explicitly selected, unhydrated row. + private async Task PrimeTypedRowsAsync() + { + if (_busy || !CurrentAssociation) return; + var candidates = Rows.Where(r => r.Eligible).Take(24).ToArray(); + SetBusy(true); + try + { + var done = 0; + foreach (var row in candidates) + { + if (!CurrentAssociation || _lifetime.IsCancellationRequested) break; + try + { + var preview = await _runtime.PreviewBrowserDataAttributeWriteAsync( + _device, row.Reference!.Value, _lifetime.Token); + if (!CurrentAssociation) break; + row.ApplyLivePreview(preview, preserveExplicitEdit: false); + done++; + } + catch (OperationCanceledException) { break; } + catch (Exception ex) { row.Status = "Read failed: " + ex.Message; } + } + StatusLabel.Text = $"Read {done}/{candidates.Length} typed DA(s). Choose a value; select its checkbox and press Read, then Write."; + } + finally { SetBusy(false); } } private BrowserBatchWriteRow[] CheckedRows() @@ -124,10 +155,18 @@ private async void Inspect_Click(object sender, RoutedEventArgs e) WriteButton.IsEnabled = false; var selected = CheckedRows(); if (selected.Length is 0 or > IedBrowserTypedWriteBatch.MaxSelectedAttributes || - selected.Any(r => !r.Eligible || !r.HasEdited) || !CurrentAssociation) + selected.Any(r => !r.Eligible) || !CurrentAssociation) { - StatusLabel.Text = - "Select 1–64 eligible DAs, explicitly edit each new value and keep the same online association. No Write sent."; + StatusLabel.Text = "Select 1–64 eligible attributes on the current IED; nothing was written."; + return; + } + + // The first Read for an uninspected row populates its type-aware + // editor without forcing the operator to invent a value in advance. + var firstRead = selected.Any(r => r.Preview is null); + if (!firstRead && selected.Any(r => !r.HasEdited)) + { + StatusLabel.Text = "Choose a different value for every selected DA before Write."; return; } @@ -141,15 +180,11 @@ private async void Inspect_Click(object sender, RoutedEventArgs e) if (!CurrentAssociation) throw new InvalidOperationException("IED association changed during inspection."); StatusLabel.Text = $"Read-only inspect {i + 1}/{selected.Length}: {row.ReferenceText}"; - row.Preview = null; var preview = await _runtime.PreviewBrowserDataAttributeWriteAsync( _device, row.Reference!.Value, _lifetime.Token); if (!CurrentAssociation) throw new InvalidOperationException("IED association changed during inspection."); - row.Preview = preview.IsAvailable ? preview : null; - row.Type = preview.Type?.MmsType ?? "unverified"; - row.Old = preview.CurrentText; - row.Status = preview.Message; + row.ApplyLivePreview(preview, preserveExplicitEdit: !firstRead); if (row.Preview is null) { StatusLabel.Text = "One DA has no valid live typed Read/GVA proof. No Write sent."; @@ -157,6 +192,11 @@ private async void Inspect_Click(object sender, RoutedEventArgs e) } } + if (firstRead) + { + StatusLabel.Text = "Live values loaded. Choose a new value and click Read to verify changes."; + return; + } if (!TryFreezePlan(selected, out _, out var rejection)) { StatusLabel.Text = rejection + " No Write sent."; @@ -306,6 +346,18 @@ public sealed class BrowserBatchWriteRow : INotifyPropertyChanged private string _status; private string _type = "Inspect first"; private string _old = "—"; + private bool _canEdit; + private bool _isBoolean; + public IReadOnlyList BooleanOptions { get; } = new[] { "false", "true" }; + public string ShortName => + Reference?.Item.Split((char)36).LastOrDefault() ?? ReferenceText; + public bool CanEdit => _canEdit; + public Visibility BooleanEditorVisibility => + _canEdit && _isBoolean ? Visibility.Visible : Visibility.Collapsed; + public Visibility TextEditorVisibility => + _canEdit && !_isBoolean ? Visibility.Visible : Visibility.Collapsed; + public Visibility PlaceholderVisibility => + _canEdit ? Visibility.Collapsed : Visibility.Visible; public MmsObjectReference? Reference { get; } public string ReferenceText { get; } @@ -326,6 +378,42 @@ public BrowserBatchWriteRow( _status = reason; } + public void ApplyLivePreview( + IedBrowserNativeWritePreview preview, bool preserveExplicitEdit) + { + Preview = preview.IsAvailable && preview.Type is not null && + preview.OldValue is not null ? preview : null; + Type = preview.Type?.MmsType ?? "unverified"; + Old = preview.CurrentText; + _isBoolean = preview.Type?.MmsType == "boolean"; + _canEdit = Eligible && Preview is not null && + (preview.Type!.MmsType is "boolean" or "integer" or "unsigned" or + "visible-string" or "mms-string" || + preview.Type.MmsType == "floating-point" && preview.Type.Size == 32); + Status = Preview is null ? preview.Message : + _canEdit ? "Ready to edit" : "Unsupported live MMS editor"; + if (!preserveExplicitEdit) + { + // Use the exact current wire value; never guess a textual enum + // description or coerce a Boolean 0/1 behind the operator. + _newValue = preview.OldValue?.Value switch + { + bool b => b ? "true" : "false", + string str => str, + long or ulong or int or uint or float or double => + Convert.ToString(preview.OldValue.Value, + System.Globalization.CultureInfo.InvariantCulture) ?? "", + _ => "" + }; + HasEdited = false; + Changed(nameof(NewValue)); + } + Changed(nameof(CanEdit)); + Changed(nameof(BooleanEditorVisibility)); + Changed(nameof(TextEditorVisibility)); + Changed(nameof(PlaceholderVisibility)); + } + public bool Selected { get => _selected; From dd5fc462830da46ca11c6f9d4ece17261fcf602f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 18:54:49 +0700 Subject: [PATCH 05/39] ux(write): carry verified compact typed legacy editor onto newest integrated FAT base --- MainWindow.IedBrowser.TypedDataWrite.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MainWindow.IedBrowser.TypedDataWrite.cs b/MainWindow.IedBrowser.TypedDataWrite.cs index b132804fe..96e8e852a 100644 --- a/MainWindow.IedBrowser.TypedDataWrite.cs +++ b/MainWindow.IedBrowser.TypedDataWrite.cs @@ -127,8 +127,8 @@ private void RefreshIedBrowserWriteButton() var selectedDo = ctlModel is null && selected is null ? BrowserWritableSelectedDo() : null; IedBrowserWriteButton.IsEnabled = ctlModel is not null || selected is not null || selectedDo is not null; - IedBrowserWriteButton.Content = ctlModel is not null ? "Write ctlModel…" : - selectedDo is not null ? "Write DAs…" : "Write DA…"; + // The button always reads "Write". Selection changes the action/tooltip, + // never the caption, width or Material pencil visual. IedBrowserWriteButton.ToolTip = ctlModel is not null ? "Read live ctlModel, choose IEC enum 0–4, confirm a guarded CF Write and verify readback." : selected is not null From ac333db3f8680470a7b0764660101b70e1099aef Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 18:55:18 +0700 Subject: [PATCH 06/39] ux(write): keep BOTH Browser Write entry points stable Material pencil plus Write label --- MainWindow.xaml | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/MainWindow.xaml b/MainWindow.xaml index 5f219a947..c46f610d1 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -2052,18 +2052,34 @@ Style="{StaticResource SoftButton}" Margin="0,0,6,0" IsEnabled="False" Click="IedBrowserControl_Click" ToolTip="Only enabled when this online DO has an existing live-verified native IEC Control descriptor."/> - + + \ No newline at end of file From 9127f10f4717cd512fe740832692396598f2f2e9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 18:58:40 +0700 Subject: [PATCH 09/39] ux(write): typed current values on open, Boolean/ctlModel selection, changed-value gate before confirmed MMS Write --- IedBrowserWriteWindow.xaml.cs | 74 ++++++++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 14 deletions(-) diff --git a/IedBrowserWriteWindow.xaml.cs b/IedBrowserWriteWindow.xaml.cs index 75501c96d..3e66f31fa 100644 --- a/IedBrowserWriteWindow.xaml.cs +++ b/IedBrowserWriteWindow.xaml.cs @@ -38,6 +38,8 @@ public IedBrowserWriteWindow( ArgumentNullException.ThrowIfNull(rows); _association = device.LiveValues.Association; Rows = new ObservableCollection(rows); + foreach (var row in Rows) + row.PropertyChanged += (_, _) => UpdateWriteEnabled(); InitializeComponent(); DataContext = this; IdentityText.Text = _device.Name + " · " + _device.IpAddress + @@ -45,8 +47,22 @@ public IedBrowserWriteWindow( " live-verified configuration DA(s)"; } - private void Window_Loaded(object sender, RoutedEventArgs e) - => StatusText.Text = "Select only the DA(s) you intend to change. Read selected to inspect current live values."; + private async void Window_Loaded(object sender, RoutedEventArgs e) + { + var work = SystemParameters.WorkArea; + Height = Math.Max(MinHeight, Math.Min(520, work.Height - 26)); + Width = Math.Max(MinWidth, Math.Min(940, work.Width - 26)); + // IEDScout-like editor: preload bounded current values from this live + // association. Read-only traffic only; never write on opening. + await ReadRowsAsync(Rows.Take(24).ToArray()); + } + + private void UpdateWriteEnabled() + { + if (WriteButton is null) return; + WriteButton.IsEnabled = !_busy && CurrentAssociation && + Rows.Any(row => row.Selected && row.HasVerifiedRead && row.HasEdited); + } private bool CurrentAssociation => _device.IsConnected && !_device.IsDemo && @@ -58,7 +74,7 @@ private void SetBusy(bool busy) { _busy = busy; ReadButton.IsEnabled = !busy; - WriteButton.IsEnabled = !busy; + UpdateWriteEnabled(); WriteRowsGrid.IsEnabled = !busy; } @@ -66,13 +82,19 @@ private async void Read_Click(object sender, RoutedEventArgs e) { if (_busy || !CurrentAssociation) return; WriteRowsGrid.CommitEdit(DataGridEditingUnit.Cell, true); + WriteRowsGrid.CommitEdit(DataGridEditingUnit.Row, true); var selected = Rows.Where(row => row.Selected).ToArray(); if (selected.Length == 0) { - StatusText.Text = "Select one or more DA rows for an explicit live MMS Read."; + StatusText.Text = "Select the Data Attributes you want to read."; return; } + await ReadRowsAsync(selected); + } + private async Task ReadRowsAsync(IedBrowserWriteRow[] selected) + { + if (_busy || !CurrentAssociation || selected.Length == 0) return; SetBusy(true); try { @@ -81,17 +103,25 @@ private async void Read_Click(object sender, RoutedEventArgs e) { _lifetime.Token.ThrowIfCancellationRequested(); if (!CurrentAssociation) - throw new InvalidOperationException("The association changed. Close and reopen the editor."); - StatusText.Text = "Reading " + (++i) + "/" + selected.Length + " · " + row.Reference; + throw new InvalidOperationException("IED association changed. Reopen Write."); + StatusText.Text = "Reading " + (++i) + "/" + selected.Length + " · " + row.ShortName; var observation = await _runtime.ReadBrowserConfigurationAsync( _device.DeviceId, _model, _association, row, _lifetime.Token); - row.Current = observation.IsSuccess && observation.Value is not null - ? MmsDataCodec.ToDisplayString(observation.Value) - : "Read failed"; - row.Result = observation.IsSuccess ? "Live Read OK" : - "Read rejected: " + observation.Message; + if (!CurrentAssociation) + throw new InvalidOperationException("IED association changed. Reopen Write."); + if (observation.IsSuccess && observation.Value is not null) + { + row.ApplyLiveRead(observation.Value); + row.Result = "Ready to edit"; + } + else + { + row.ExpireLiveRead(); + row.Current = "Read unavailable"; + row.Result = "Read failed: " + observation.Message; + } } - StatusText.Text = "Live MMS Read completed; no configuration was changed."; + StatusText.Text = "Choose a value, tick its row and click Write. No Write sent."; } catch (OperationCanceledException) { @@ -115,6 +145,12 @@ private async void Write_Click(object sender, RoutedEventArgs e) StatusText.Text = "Select at least one DA; no Write was sent."; return; } + if (selected.Any(row => !row.HasVerifiedRead || !row.HasEdited || + row.LiveBeforeValue is null)) + { + StatusText.Text = "Read the selected DAs and choose a different value before Write."; + return; + } if (selected.Length > 16) { StatusText.Text = "At most 16 explicitly staged DA(s) per supervised Write. Refine the selection."; @@ -125,8 +161,17 @@ private async void Write_Click(object sender, RoutedEventArgs e) try { foreach (var row in selected) - staged.Add((row, IedBrowserWritePlan.EncodeValue( - row.DataType, row.RequestedValue, row.Reference))); + { + var candidate = IedBrowserWritePlan.EncodeValue( + row.DataType, row.RequestedValue, row.Reference); + if (MmsDataCodec.Encode(candidate).AsSpan().SequenceEqual( + MmsDataCodec.Encode(row.LiveBeforeValue!))) + { + StatusText.Text = row.ShortName + ": unchanged value. No Write sent."; + return; + } + staged.Add((row, candidate)); + } } catch (Exception ex) when (ex is FormatException or OverflowException or NotSupportedException) { @@ -165,6 +210,7 @@ private async void Write_Click(object sender, RoutedEventArgs e) StatusText.Text = "Writing " + (passed + 1) + "/" + staged.Count + " · " + row.Reference; row.Result = "Write / readback pending…"; + row.ExpireLiveRead(); // each attempt requires a NEW explicit Read if (row.IsControlModel) PotentiallyChangedControlModels.Add(row.Reference[..^".ctlModel".Length]); var result = await _runtime.WriteBrowserConfigurationAsync( From 9997f1196b6b703995efcf1bf1e0f4909a02f9a8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 18:59:42 +0700 Subject: [PATCH 10/39] test(write): preserve earlier FAT controls and static write caption assertions --- tests/ARSAS.Tests/IedBrowserBatchWriteEditorTests.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/IedBrowserBatchWriteEditorTests.cs b/tests/ARSAS.Tests/IedBrowserBatchWriteEditorTests.cs index 659379a26..2155ac8ce 100644 --- a/tests/ARSAS.Tests/IedBrowserBatchWriteEditorTests.cs +++ b/tests/ARSAS.Tests/IedBrowserBatchWriteEditorTests.cs @@ -84,8 +84,11 @@ public void StatusDaSelectionRevokesParentWriteAndControlAuthority() Assert.Contains(dataLeaf, write); Assert.Contains(dataLeaf, control); Assert.Contains("RefreshIedBrowserControlButton();", write); - Assert.Contains("IedBrowserWriteButton.Content = ctlModel is not null", write); - Assert.Contains("selectedDo is not null ? \"Write DAs…\" : \"Write DA…\"", write); + Assert.DoesNotContain("IedBrowserWriteButton.Content =", write); + Assert.Contains("BrowserSelectedControlModelSignal()", write); + var browserXaml = File.ReadAllText(Find("MainWindow.xaml")); + Assert.Contains("x:Name=\"IedBrowserWriteButton\"", browserXaml); + Assert.Contains("", browserXaml); } private static string Find(string relative) From 6fe2761b8709943a91f47eaaebcfd34822b71a92 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 18:59:48 +0700 Subject: [PATCH 11/39] test(write): preserve earlier FAT controls and static write caption assertions --- .../IedBrowserLiveControlWriteFatRegressionTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/IedBrowserLiveControlWriteFatRegressionTests.cs b/tests/ARSAS.Tests/IedBrowserLiveControlWriteFatRegressionTests.cs index 944d58b4e..7292d6ba2 100644 --- a/tests/ARSAS.Tests/IedBrowserLiveControlWriteFatRegressionTests.cs +++ b/tests/ARSAS.Tests/IedBrowserLiveControlWriteFatRegressionTests.cs @@ -43,7 +43,8 @@ public void CtlModelCfLeaf_UsesExistingGuardedWriteEditor_NotGenericMmsOrOperate Assert.Contains("source.Attributes.Any", ctx); Assert.Contains("new ControlModelWriteWindow(_runtime, device, modelSignal)", write); Assert.Contains("ctlModel requires the existing guarded ConfigureControlModel workflow", safety); - Assert.Contains("selectedDo is not null ? \"Write DAs…\" : \"Write DA…\"", write); + Assert.DoesNotContain("IedBrowserWriteButton.Content =", write); + Assert.Contains("new ControlModelWriteWindow(_runtime, device, modelSignal)", write); } [Fact] From 40a2200ca739cfba64162d125047cc6465c0385b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:00:04 +0700 Subject: [PATCH 12/39] test(write): regression guards for scalar, batch and unified native editors with Material Write caption --- ...serWriteEditorMaterialUxRegressionTests.cs | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 tests/ARSAS.Tests/IedBrowserWriteEditorMaterialUxRegressionTests.cs diff --git a/tests/ARSAS.Tests/IedBrowserWriteEditorMaterialUxRegressionTests.cs b/tests/ARSAS.Tests/IedBrowserWriteEditorMaterialUxRegressionTests.cs new file mode 100644 index 000000000..8aed132ad --- /dev/null +++ b/tests/ARSAS.Tests/IedBrowserWriteEditorMaterialUxRegressionTests.cs @@ -0,0 +1,97 @@ +using System.Xml.Linq; + +namespace ARSAS.Tests; + +public sealed class IedBrowserWriteEditorMaterialUxRegressionTests +{ + private static readonly XNamespace P = + "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; + private static readonly XNamespace X = + "http://schemas.microsoft.com/winfx/2006/xaml"; + + [Fact] + public void SingleDa_HasVisibleBooleanPickerAndNoBlankUnqualifiedFreeText() + { + var doc = XDocument.Parse(Read("BrowserDataWriteWindow.xaml")); + Assert.Contains(doc.Descendants(P + "ComboBox"), element => + (string?)element.Attribute(X + "Name") == "BooleanPicker"); + Assert.Contains(doc.Descendants(P + "TextBox"), element => + (string?)element.Attribute(X + "Name") == "NewValueBox" && + (string?)element.Attribute("IsEnabled") == "False"); + Assert.Contains(doc.Descendants(P + "Button"), element => + (string?)element.Attribute(X + "Name") == "ReadButton"); + var source = Read("BrowserDataWriteWindow.xaml.cs"); + Assert.Contains("BooleanPicker.ItemsSource = new[] { \"false\", \"true\" }", source); + Assert.Contains("SelectedInput()", source); + Assert.Contains("p.OldValue.Value", source); + Assert.Contains("MessageBoxResult.Yes", source); + Assert.Contains("WriteBrowserDataAttributesAsync(", source); + Assert.DoesNotContain("OperateAsync(", source); + } + + [Fact] + public void BatchDa_KeepsPreciseReferenceAsTooltip_ButUsesShortNamesAndTypedEditors() + { + var doc = XDocument.Parse(Read("BrowserBatchWriteWindow.xaml")); + var rows = doc.Descendants(P + "DataGrid").Single(x => + (string?)x.Attribute(X + "Name") == "RowsGrid"); + Assert.Equal("34", (string?)rows.Attribute("RowHeight")); + Assert.Contains(doc.Descendants(P + "DataGridTextColumn"), col => + (string?)col.Attribute("Header") == "Data Attribute" && + (string?)col.Attribute("Binding") == "{Binding ShortName}"); + Assert.Contains(doc.Descendants(P + "ComboBox"), el => + (string?)el.Attribute("SelectedItem") == + "{Binding NewValue, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" && + (string?)el.Attribute("IsEnabled") == "{Binding CanEdit}"); + Assert.Contains(doc.Descendants(P + "TextBox"), el => + (string?)el.Attribute("Text") == + "{Binding NewValue, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" && + (string?)el.Attribute("IsEnabled") == "{Binding CanEdit}"); + Assert.Contains(doc.Descendants(P + "TextBlock"), el => + (string?)el.Attribute("Text") == "Read to edit"); + var code = Read("BrowserBatchWriteWindow.xaml.cs"); + Assert.Contains("Take(24)", code); // bounded initial read-only previews + Assert.Contains("ApplyLivePreview(preview, preserveExplicitEdit: false)", code); + Assert.Contains("IedBrowserTypedWriteBatch.Freeze(pending)", code); + Assert.Contains("MessageBoxResult.Yes", code); + Assert.Contains("CurrentAssociation", code); + Assert.DoesNotContain("OperateAsync(", code); + } + + [Fact] + public void AllWriteButtons_StableCaptionAndMaterialPencil_WithoutSelectionTextRewrites() + { + foreach (var file in new[] + { + "BrowserDataWriteWindow.xaml", "BrowserBatchWriteWindow.xaml", + "MainWindow.xaml", "ControlModelWriteWindow.xaml", "IedBrowserWriteWindow.xaml" + }) + { + var doc = XDocument.Parse(Read(file)); + var buttons = doc.Descendants(P + "Button").Where(x => + (string?)x.Attribute(X + "Name") is "WriteButton" or "IedBrowserWriteButton").ToArray(); + Assert.NotEmpty(buttons); + foreach (var button in buttons) + { + Assert.True((string?)button.Attribute("Content") == "Write" || + button.Descendants(P + "TextBlock").Any(t => (string?)t.Attribute("Text") == "Write"), + file + ": Write caption must never be changed by selection."); + } + } + foreach (var file in new[] { "MainWindow.xaml", "BrowserDataWriteWindow.xaml", "BrowserBatchWriteWindow.xaml", "IedBrowserWriteWindow.xaml" }) + Assert.Contains("M167-120q-21 5-36.5-10.5T120-167l40-191", Read(file)); + Assert.DoesNotContain("IedBrowserWriteButton.Content =", Read("MainWindow.IedBrowser.TypedDataWrite.cs")); + } + + private static string Read(string relative) + { + DirectoryInfo? dir = new(AppContext.BaseDirectory); + while (dir is not null) + { + var path = Path.Combine(dir.FullName, relative); + if (File.Exists(path)) return File.ReadAllText(path); + dir = dir.Parent; + } + throw new FileNotFoundException(relative); + } +} From 6ee863ddd64640220e7cbc53c7d8b90ebe0c7a04 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:00:37 +0700 Subject: [PATCH 13/39] test(write): enforce read-backed Boolean and ctlModel editors, explicit edit and no automatic MMS Write --- .../IedBrowserUnifiedWriteUxTests.cs | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/ARSAS.Tests/IedBrowserUnifiedWriteUxTests.cs diff --git a/tests/ARSAS.Tests/IedBrowserUnifiedWriteUxTests.cs b/tests/ARSAS.Tests/IedBrowserUnifiedWriteUxTests.cs new file mode 100644 index 000000000..b27bdf136 --- /dev/null +++ b/tests/ARSAS.Tests/IedBrowserUnifiedWriteUxTests.cs @@ -0,0 +1,87 @@ +using System.Xml.Linq; +using AR.Iec61850.Mms; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +public sealed class IedBrowserUnifiedWriteUxTests +{ + [Fact] + public void WriteRow_RequiresLiveReadAndExplicitUserChange() + { + var row = new IedBrowserWriteRow("LD0/LLN0.Pos.stVal", "CF", "BOOLEAN"); + Assert.Equal("stVal", row.ShortName); + Assert.True(row.IsBoolean); + Assert.False(row.HasVerifiedRead); + Assert.False(row.HasEdited); + row.ApplyLiveRead(MmsDataValue.Boolean(false)); + Assert.Equal("false", row.RequestedValue); + Assert.Equal("false", row.Current); + Assert.False(row.HasEdited); + Assert.True(row.HasVerifiedRead); + row.RequestedValue = "true"; + Assert.True(row.HasEdited); + row.ExpireLiveRead(); + Assert.False(row.HasVerifiedRead); + Assert.Null(row.LiveBeforeValue); + } + + [Fact] + public void CtlModel_IsStillEnumeratedNotFreeText() + { + var row = new IedBrowserWriteRow("LD0/CSWI1.Pos.ctlModel", "CF", "ENUM"); + Assert.True(row.IsControlModel); + row.ApplyLiveRead(MmsDataValue.Integer(2)); + Assert.Equal("2", row.RequestedValue); + Assert.Equal(System.Windows.Visibility.Visible, row.CtlModelEditorVisibility); + Assert.Equal(System.Windows.Visibility.Collapsed, row.TextEditorVisibility); + } + + [Fact] + public void UnifiedWindow_DoesNotRequestWriteDuringReadOrOpen() + { + var src = Read("IedBrowserWriteWindow.xaml.cs"); + var inspect = src.Split("private async Task ReadRowsAsync(")[1] + .Split("private async void Write_Click(")[0]; + Assert.Contains("ReadBrowserConfigurationAsync(", inspect); + Assert.DoesNotContain("WriteBrowserConfigurationAsync(", inspect); + Assert.Contains("row.ApplyLiveRead(observation.Value)", inspect); + Assert.Contains("Take(24)", src); + Assert.Contains("!row.HasVerifiedRead || !row.HasEdited", src); + Assert.Contains("MmsDataCodec.Encode(row.LiveBeforeValue!)", src); + Assert.Contains("MessageBoxResult.Yes", src); + Assert.Contains("row.ExpireLiveRead()", src); + } + + [Fact] + public void UnifiedWindow_HasStableMaterialWriteAndTypeAwareGrid() + { + var x = XDocument.Parse(Read("IedBrowserWriteWindow.xaml")); + XNamespace p = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; + XNamespace xNs = "http://schemas.microsoft.com/winfx/2006/xaml"; + Assert.Contains(x.Descendants(p + "DataGridTextColumn"), t => + (string?)t.Attribute("Binding") == "{Binding ShortName}"); + Assert.Contains(x.Descendants(p + "ComboBox"), t => + (string?)t.Attribute(xNs + "Name") == "BooleanPicker"); + Assert.Contains(x.Descendants(p + "ComboBox"), t => + (string?)t.Attribute(xNs + "Name") == "CtlPicker"); + var write = x.Descendants(p + "Button").Single(t => + (string?)t.Attribute(xNs + "Name") == "WriteButton"); + Assert.True(write.Descendants(p + "TextBlock").Any(t => + (string?)t.Attribute("Text") == "Write")); + Assert.Contains("M167-120q-21 5-36.5-10.5T120-167", Read("IedBrowserWriteWindow.xaml")); + Assert.Equal("34", (string?)x.Descendants(p + "DataGrid").Single().Attribute("RowHeight")); + } + + private static string Read(string file) + { + DirectoryInfo? dir = new(AppContext.BaseDirectory); + while (dir is not null) + { + var candidate = Path.Combine(dir.FullName, file); + if (File.Exists(candidate)) return File.ReadAllText(candidate); + dir = dir.Parent; + } + throw new FileNotFoundException(file); + } +} From b896e779615d9581b04fd558f95de407d24eee5a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:02:15 +0700 Subject: [PATCH 14/39] fix(cleanroom): keep UX code and comments independent of named external software --- BrowserBatchWriteWindow.xaml.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BrowserBatchWriteWindow.xaml.cs b/BrowserBatchWriteWindow.xaml.cs index ee1c5f58e..54f5a1177 100644 --- a/BrowserBatchWriteWindow.xaml.cs +++ b/BrowserBatchWriteWindow.xaml.cs @@ -112,7 +112,7 @@ private async void Window_Loaded(object sender, RoutedEventArgs e) } // The first bounded visible batch is read-only hydrated on open, like - // IEDScout. No blank "new value" textbox and NO hidden Write/Operate. + // reference engineering application. No blank "new value" textbox and NO hidden Write/Operate. // Larger DOs can use Read on an explicitly selected, unhydrated row. private async Task PrimeTypedRowsAsync() { From ae6421e42c92954a0b0afe50fd6988b402615cc9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:02:20 +0700 Subject: [PATCH 15/39] fix(cleanroom): keep UX code and comments independent of named external software --- BrowserDataWriteWindow.xaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BrowserDataWriteWindow.xaml b/BrowserDataWriteWindow.xaml index eadeff6d7..6cb82a96d 100644 --- a/BrowserDataWriteWindow.xaml +++ b/BrowserDataWriteWindow.xaml @@ -36,7 +36,7 @@ TextTrimming="CharacterEllipsis" ToolTip="{Binding Text, RelativeSource={RelativeSource Self}}"/> - + From 73af5bbbafe0f757da2164d2e975a2152c9a1567 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:02:25 +0700 Subject: [PATCH 16/39] fix(cleanroom): keep UX code and comments independent of named external software --- IedBrowserWriteWindow.xaml.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/IedBrowserWriteWindow.xaml.cs b/IedBrowserWriteWindow.xaml.cs index 3e66f31fa..f0461a97b 100644 --- a/IedBrowserWriteWindow.xaml.cs +++ b/IedBrowserWriteWindow.xaml.cs @@ -52,7 +52,7 @@ private async void Window_Loaded(object sender, RoutedEventArgs e) var work = SystemParameters.WorkArea; Height = Math.Max(MinHeight, Math.Min(520, work.Height - 26)); Width = Math.Max(MinWidth, Math.Min(940, work.Width - 26)); - // IEDScout-like editor: preload bounded current values from this live + // operator-friendly editor: preload bounded current values from this live // association. Read-only traffic only; never write on opening. await ReadRowsAsync(Rows.Take(24).ToArray()); } From 5789cf17cc9a8ebb0a703a0d29662bfcb865b3ad Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:05:22 +0700 Subject: [PATCH 17/39] fix(write): exact vector of user-provided Material edit icon in unified Write toolbar --- MainWindow.xaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MainWindow.xaml b/MainWindow.xaml index c46f610d1..73e6d1b17 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -2074,7 +2074,7 @@ ToolTip="Open explicit native MMS configuration Write for the selected live DO/DA; never sends Operate"> - From ac13b6c224d942b3c504262763f293edfbaae37e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:09:18 +0700 Subject: [PATCH 18/39] ux(write): unify two legacy and native config workflows behind one fixed Write action --- MainWindow.IedBrowser.TypedDataWrite.cs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/MainWindow.IedBrowser.TypedDataWrite.cs b/MainWindow.IedBrowser.TypedDataWrite.cs index 96e8e852a..a8e1c78f7 100644 --- a/MainWindow.IedBrowser.TypedDataWrite.cs +++ b/MainWindow.IedBrowser.TypedDataWrite.cs @@ -125,8 +125,15 @@ private void RefreshIedBrowserWriteButton() var ctlModel = BrowserSelectedControlModelSignal(); var selected = ctlModel is null ? BrowserWritableSelectedAttribute() : null; var selectedDo = ctlModel is null && selected is null ? BrowserWritableSelectedDo() : null; + // A single fixed Write button owns both existing typed and expanded + // configuration editors. The UI never shows two competing Write actions. + var currentPage = _iedBrowserNavigation.Current; + var modernScope = SelectedDevice is { IsConnected: true, IsDemo: false, IsBusy: false } device && + device.LiveDiscoveryModel is not null && _iedBrowserReadOperation is null && + (currentPage?.Kind is "DO" or "DA" || + currentPage?.Kind == "LN" && !string.IsNullOrWhiteSpace(_iedBrowserSelectedDoReference)); IedBrowserWriteButton.IsEnabled = - ctlModel is not null || selected is not null || selectedDo is not null; + ctlModel is not null || selected is not null || selectedDo is not null || modernScope; // The button always reads "Write". Selection changes the action/tooltip, // never the caption, width or Material pencil visual. IedBrowserWriteButton.ToolTip = ctlModel is not null @@ -154,10 +161,12 @@ private void IedBrowserWrite_Click(object sender, RoutedEventArgs e) var modelSignal = BrowserSelectedControlModelSignal(); var attribute = modelSignal is null ? BrowserWritableSelectedAttribute() : null; var owner = modelSignal is null && attribute is null ? BrowserWritableSelectedDo() : null; - if (device is null || (modelSignal is null && attribute is null && owner is null)) + if (device is null) return; + if (modelSignal is null && attribute is null && owner is null) { - IedBrowserSubtitle.Text = - "Write requires a current online CF/SP/DC attribute or exact owning DO."; + // SG/SE and other native supported configuration leaves retain + // their dedicated unified typed editor. No additional button. + IedBrowserWriteValues_Click(sender, e); RefreshIedBrowserWriteButton(); return; } From 0e99827277e4e59d3d33d5dbd4e8d6c4ea7c4fd4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:09:33 +0700 Subject: [PATCH 19/39] ux(write): hide duplicate config button; native editor remains reachable via the single Write router --- MainWindow.xaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MainWindow.xaml b/MainWindow.xaml index 73e6d1b17..2298d8a54 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -2068,7 +2068,7 @@ Style="{StaticResource SoftButton}" Margin="0,0,6,0" Click="IedBrowserReadValues_Click" ToolTip="Read values for the current Browser object over MMS. Live monitoring uses the report stream."/> -