diff --git a/App.xaml b/App.xaml
index b134cf642..7be070301 100644
--- a/App.xaml
+++ b/App.xaml
@@ -689,7 +689,9 @@
-
+
+
diff --git a/IedBrowserWriteWindow.xaml b/IedBrowserWriteWindow.xaml
new file mode 100644
index 000000000..e760ab416
--- /dev/null
+++ b/IedBrowserWriteWindow.xaml
@@ -0,0 +1,111 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/IedBrowserWriteWindow.xaml.cs b/IedBrowserWriteWindow.xaml.cs
new file mode 100644
index 000000000..75501c96d
--- /dev/null
+++ b/IedBrowserWriteWindow.xaml.cs
@@ -0,0 +1,200 @@
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Windows;
+using System.Windows.Controls;
+using AR.Iec61850.Discovery;
+using AR.Iec61850.Mms;
+using ArIED61850Tester.Models;
+using ArIED61850Tester.Services;
+
+namespace ArIED61850Tester;
+
+///
+/// Operator-initiated IEC 61850 MMS Write dialog, not an Operate command.
+/// UI stages edits, runtime validates current association and ARIEC performs
+/// the single native wire Write with independent before/after typed MMS reads.
+///
+public partial class IedBrowserWriteWindow : Window
+{
+ private readonly Iec61850MonitorRuntime _runtime;
+ private readonly Iec61850MonitorDevice _device;
+ private readonly LiveIedModelDiscoveryDocument _model;
+ private readonly long _association;
+ private readonly CancellationTokenSource _lifetime = new();
+ private bool _busy;
+ // A ctlModel Write may reach the wire even if cancellation hides its reply.
+ // Invalidate stale Command Dock permission on modal close in that case.
+ public HashSet PotentiallyChangedControlModels { get; } =
+ new(StringComparer.OrdinalIgnoreCase);
+ public ObservableCollection Rows { get; }
+
+ public IedBrowserWriteWindow(
+ Iec61850MonitorRuntime runtime, Iec61850MonitorDevice device,
+ LiveIedModelDiscoveryDocument model, IReadOnlyList rows)
+ {
+ _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
+ _device = device ?? throw new ArgumentNullException(nameof(device));
+ _model = model ?? throw new ArgumentNullException(nameof(model));
+ ArgumentNullException.ThrowIfNull(rows);
+ _association = device.LiveValues.Association;
+ Rows = new ObservableCollection(rows);
+ InitializeComponent();
+ DataContext = this;
+ IdentityText.Text = _device.Name + " · " + _device.IpAddress +
+ " · association " + _association + " · " + Rows.Count +
+ " 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 bool CurrentAssociation =>
+ _device.IsConnected && !_device.IsDemo &&
+ !_lifetime.IsCancellationRequested &&
+ ReferenceEquals(_device.LiveDiscoveryModel, _model) &&
+ _device.LiveValues.Association == _association;
+
+ private void SetBusy(bool busy)
+ {
+ _busy = busy;
+ ReadButton.IsEnabled = !busy;
+ WriteButton.IsEnabled = !busy;
+ WriteRowsGrid.IsEnabled = !busy;
+ }
+
+ private async void Read_Click(object sender, RoutedEventArgs e)
+ {
+ if (_busy || !CurrentAssociation) return;
+ WriteRowsGrid.CommitEdit(DataGridEditingUnit.Cell, 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.";
+ return;
+ }
+
+ SetBusy(true);
+ try
+ {
+ var i = 0;
+ foreach (var row in selected)
+ {
+ _lifetime.Token.ThrowIfCancellationRequested();
+ if (!CurrentAssociation)
+ throw new InvalidOperationException("The association changed. Close and reopen the editor.");
+ StatusText.Text = "Reading " + (++i) + "/" + selected.Length + " · " + row.Reference;
+ 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;
+ }
+ StatusText.Text = "Live MMS Read completed; no configuration was changed.";
+ }
+ catch (OperationCanceledException)
+ {
+ StatusText.Text = "Read cancelled; no Write attempted.";
+ }
+ catch (Exception ex)
+ {
+ StatusText.Text = "Read stopped: " + ex.Message;
+ }
+ finally { SetBusy(false); }
+ }
+
+ private async void Write_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 at least one DA; no Write was sent.";
+ return;
+ }
+ if (selected.Length > 16)
+ {
+ StatusText.Text = "At most 16 explicitly staged DA(s) per supervised Write. Refine the selection.";
+ return;
+ }
+
+ var staged = new List<(IedBrowserWriteRow Row, MmsDataValue Value)>();
+ try
+ {
+ foreach (var row in selected)
+ staged.Add((row, IedBrowserWritePlan.EncodeValue(
+ row.DataType, row.RequestedValue, row.Reference)));
+ }
+ catch (Exception ex) when (ex is FormatException or OverflowException or NotSupportedException)
+ {
+ StatusText.Text = "Typed validation failed; zero Writes sent: " + ex.Message;
+ return;
+ }
+
+ var review = string.Join(Environment.NewLine,
+ selected.Select(row => " " + row.Reference + " [" + row.FunctionalConstraint +
+ "] = " + row.RequestedValue));
+ var notice = "IED: " + _device.Name + " (" + _device.IpAddress + ")" +
+ Environment.NewLine + Environment.NewLine +
+ "Change these " + selected.Length + " configuration DA(s)?" +
+ Environment.NewLine + review + Environment.NewLine + Environment.NewLine +
+ "These are real MMS Writes. A ctlModel change may change permitted IEC control modes." +
+ Environment.NewLine +
+ "The operation is sequential, NOT ATOMIC. Stops on the first failure; no automatic retry." +
+ Environment.NewLine + "Proceed only with authorized, isolated commissioning.";
+ if (MessageBox.Show(this, notice, "Confirm live MMS configuration Write",
+ MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No) !=
+ MessageBoxResult.Yes)
+ {
+ StatusText.Text = "Operator cancelled. Zero Writes sent.";
+ return;
+ }
+
+ SetBusy(true);
+ var passed = 0;
+ try
+ {
+ foreach (var (row, requested) in staged)
+ {
+ _lifetime.Token.ThrowIfCancellationRequested();
+ if (!CurrentAssociation)
+ throw new InvalidOperationException("Live MMS association/model changed. No further Write.");
+ StatusText.Text = "Writing " + (passed + 1) + "/" + staged.Count +
+ " · " + row.Reference;
+ row.Result = "Write / readback pending…";
+ if (row.IsControlModel)
+ PotentiallyChangedControlModels.Add(row.Reference[..^".ctlModel".Length]);
+ var result = await _runtime.WriteBrowserConfigurationAsync(
+ _device.DeviceId, _model, _association, row, requested, _lifetime.Token);
+ row.Current = result.ReadbackVerified ? result.After : result.Before;
+ row.Result = result.NoChange ? "Unchanged · verified" :
+ result.Succeeded ? "Write + readback PASS" :
+ "NOT VERIFIED: " + result.Detail;
+ if (!result.Succeeded)
+ {
+ StatusText.Text = "STOP: " + passed + " of " + staged.Count +
+ " completed; " + row.Reference + ": " + result.Detail +
+ " Later rows were NOT sent. Verify on IED before retry.";
+ return;
+ }
+ passed++;
+ }
+ StatusText.Text = "Verified " + passed + "/" + staged.Count +
+ " configuration DA(s) using native MMS readback. No IEC Operate issued.";
+ }
+ catch (Exception ex)
+ {
+ StatusText.Text = "STOP after " + passed + " completed · " + ex.Message +
+ " Outcome of interrupted Write may be UNKNOWN. Never retry automatically.";
+ }
+ finally { SetBusy(false); }
+ }
+
+ private void Close_Click(object sender, RoutedEventArgs e) => Close();
+
+ private void Window_Closing(object? sender, CancelEventArgs e)
+ => _lifetime.Cancel();
+}
diff --git a/MainWindow.IedBrowser.Write.cs b/MainWindow.IedBrowser.Write.cs
new file mode 100644
index 000000000..1967943b0
--- /dev/null
+++ b/MainWindow.IedBrowser.Write.cs
@@ -0,0 +1,58 @@
+using System.Windows;
+using ArIED61850Tester.Models;
+
+namespace ArIED61850Tester;
+
+public partial class MainWindow
+{
+ private void IedBrowserWriteValues_Click(object sender, RoutedEventArgs e)
+ {
+ var device = SelectedDevice;
+ var liveModel = device?.LiveDiscoveryModel;
+ if (device is null || liveModel is null || !device.IsConnected || device.IsDemo ||
+ device.IsBusy || _iedBrowserReadOperation is not null)
+ {
+ IedBrowserDetailStatus.Text =
+ "Connect a real IED with LIVE discovery before configuration Write.";
+ return;
+ }
+ var page = _iedBrowserNavigation.Current;
+ var selectedDo = page?.Kind == "LN" ? _iedBrowserSelectedDoReference : null;
+ var plan = IedBrowserWritePlan.Build(liveModel, page, selectedDo);
+ if (plan.Count == 0)
+ {
+ IedBrowserDetailStatus.Text =
+ "No supported live CF/DC/SP/SG/SE primitive DA in the selected object. " +
+ "Select a specific Data Object (DO) or Data Attribute (DA), not a whole LN.";
+ return;
+ }
+ // The editor retains exact model and association identities. A late
+ // reconnect cannot turn its staged values into a write on a new IED.
+ var editor = new IedBrowserWriteWindow(_runtime, device, liveModel, plan)
+ {
+ Owner = this
+ };
+ editor.ShowDialog();
+ if (editor.PotentiallyChangedControlModels.Count > 0 &&
+ device.IsConnected && ReferenceEquals(device.LiveDiscoveryModel, liveModel))
+ {
+ // Once a configuration Write was attempted, the previous live
+ // ctlModel evidence is no longer authoritative for Operate.
+ // Refuse stale green command buttons until fresh typed inspection.
+ foreach (var signal in device.Signals.Where(signal =>
+ editor.PotentiallyChangedControlModels.Contains(signal.ObjectReference)))
+ {
+ signal.ClearLiveControlAuthority();
+ signal.ControlModelText = "Auto-detect";
+ signal.ControlInspectionEvidence =
+ "ctlModel Write attempted in Browser; fresh typed inspection required.";
+ signal.ClearControlConfirmation();
+ }
+ device.RefreshCommandSignalProjection();
+ ResetControlPreloadAttempts(device);
+ _ = PreloadControlModelsAsync();
+ }
+ IedBrowserDetailStatus.Text =
+ "MMS configuration editor closed. Use explicit Read to refresh visible live values.";
+ }
+}
diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs
index 7e48555e3..08a7aac63 100644
--- a/MainWindow.IedBrowser.cs
+++ b/MainWindow.IedBrowser.cs
@@ -911,6 +911,14 @@ private void RefreshIedBrowserReadButtons()
IedBrowserReadPageButton.Content = "Read LN";
IedBrowserReadPageButton.IsEnabled = ready;
IedBrowserReadAllButton.IsEnabled = ready;
+ if (IedBrowserWriteButton is not null)
+ {
+ var current = _iedBrowserNavigation.Current;
+ IedBrowserWriteButton.IsEnabled = device is { IsConnected: true, IsDemo: false } &&
+ device.LiveDiscoveryModel is not null && _iedBrowserReadOperation is null &&
+ (current?.Kind is "DO" or "DA" ||
+ current?.Kind == "LN" && !string.IsNullOrWhiteSpace(_iedBrowserSelectedDoReference));
+ }
if (IedBrowserExportReadEvidenceButton is not null)
IedBrowserExportReadEvidenceButton.IsEnabled =
device?.LiveDiscoveryModel is not null &&
diff --git a/MainWindow.WorkbenchP1PanelGeometry.cs b/MainWindow.WorkbenchP1PanelGeometry.cs
index 041472ea3..e7bee2b91 100644
--- a/MainWindow.WorkbenchP1PanelGeometry.cs
+++ b/MainWindow.WorkbenchP1PanelGeometry.cs
@@ -29,6 +29,7 @@ private sealed class P1PanelGeometryState
public required GridSplitter DockSplitter { get; init; }
public required Border DockGuide { get; init; }
public bool DockDragging { get; set; }
+ public bool ApplyingGeometry { get; set; }
public Dictionary DockHeightByWorkspace { get; } = new()
{
// First paint should expose a complete command row without forcing the operator
@@ -104,10 +105,19 @@ private void TryInstallP1PanelGeometry()
// P0 used row 1 directly for the command Expander. Insert a real gutter row so the
// dock can use the same quiet resizer language as the Explorer.
var dockRow = shell.RowDefinitions[1];
+ // The workspace is the remaining star row. A fixed dock height must
+ // never be measured outside the window's real client area.
+ shell.RowDefinitions[0].Height = new GridLength(1, GridUnitType.Star);
+ shell.RowDefinitions[0].MinHeight = 0;
+ shell.ClipToBounds = true;
var dockGutterRow = new RowDefinition { Height = new GridLength(0) };
shell.RowDefinitions.Insert(1, dockGutterRow);
CommandPanelExpander.Margin = new Thickness(0);
+ CommandPanelExpander.MinHeight = 0;
+ CommandPanelExpander.ClipToBounds = true;
+ EquipmentCommandGrid.MinHeight = 0;
+ EquipmentCommandGrid.SetValue(ScrollViewer.VerticalScrollBarVisibilityProperty, ScrollBarVisibility.Auto);
Grid.SetRow(CommandPanelExpander, 2);
Grid.SetRowSpan(CommandPanelExpander, 1);
@@ -172,6 +182,7 @@ private void TryInstallP1PanelGeometry()
CommandPanelExpander.Collapsed += P1DockExpander_ExpandedCollapsed;
CommandPanelExpander.IsVisibleChanged += P1DockExpander_IsVisibleChanged;
MainTabs.SelectionChanged += P1PanelGeometry_MainTabsSelectionChanged;
+ shell.SizeChanged += P1DockShell_SizeChanged;
Closed += P1PanelGeometry_Closed;
ApplyP1DockGeometry(state, MainTabs.SelectedIndex);
@@ -259,6 +270,7 @@ private void P1DockSplitter_DragCompleted(object sender, DragCompletedEventArgs
state.DockHeightByWorkspace[MainTabs.SelectedIndex] = measured;
}
+ ApplyP1DockGeometry(state, MainTabs.SelectedIndex);
var target = state.DockSplitter.IsMouseOver ? 0.96 : 0;
AnimateP1SplitterGuide(state.DockGuide, target, 130);
}
@@ -289,8 +301,19 @@ private void P1PanelGeometry_MainTabsSelectionChanged(object sender, SelectionCh
}));
}
+ private void P1DockShell_SizeChanged(object sender, SizeChangedEventArgs e)
+ {
+ if (_p1PanelGeometry is { DockDragging: false, ApplyingGeometry: false } state &&
+ e.HeightChanged)
+ ApplyP1DockGeometry(state, MainTabs.SelectedIndex);
+ }
+
private void ApplyP1DockGeometry(P1PanelGeometryState state, int workspaceIndex)
{
+ if (state.ApplyingGeometry || state.DockDragging) return;
+ state.ApplyingGeometry = true;
+ try
+ {
var dockActive = CommandPanelExpander.IsVisible && CommandPanelExpander.IsExpanded;
if (!dockActive)
{
@@ -303,17 +326,25 @@ private void ApplyP1DockGeometry(P1PanelGeometryState state, int workspaceIndex)
return;
}
- var availableHeight = Math.Max(520, _persistentWorkbench?.Shell.ActualHeight ?? ActualHeight);
+ // ActualHeight, never a fictitious 520px minimum. The previous floor
+ // could leave the dock below the visible window at low DPI/resize.
+ var availableHeight = _persistentWorkbench?.Shell.ActualHeight ?? ActualHeight;
+ if (!double.IsFinite(availableHeight) || availableHeight <= 0) return;
var preferred = state.DockHeightByWorkspace.TryGetValue(workspaceIndex, out var stored)
? stored
: 224d;
- preferred = Math.Clamp(preferred, 126d, Math.Min(430d, availableHeight * 0.48d));
+ // Keep at least 120 DIP for the live workspace and 10 for the splitter.
+ // On very short windows prioritize staying on-screen over dock rows.
+ var maxDock = Math.Max(0d, Math.Min(440d, availableHeight - 130d));
+ preferred = Math.Min(Math.Max(0, preferred), maxDock);
state.DockGutterRow.Height = new GridLength(10);
state.DockSplitter.Visibility = Visibility.Visible;
- state.DockRow.MinHeight = 112;
- state.DockRow.MaxHeight = Math.Min(440, availableHeight * 0.52d);
+ state.DockRow.MinHeight = 0;
+ state.DockRow.MaxHeight = maxDock;
state.DockRow.Height = new GridLength(preferred);
+ }
+ finally { state.ApplyingGeometry = false; }
}
private void P1PanelGeometry_Closed(object? sender, EventArgs e)
@@ -331,6 +362,8 @@ private void P1PanelGeometry_Closed(object? sender, EventArgs e)
CommandPanelExpander.Collapsed -= P1DockExpander_ExpandedCollapsed;
CommandPanelExpander.IsVisibleChanged -= P1DockExpander_IsVisibleChanged;
MainTabs.SelectionChanged -= P1PanelGeometry_MainTabsSelectionChanged;
+ if (_persistentWorkbench is { } workbench)
+ workbench.Shell.SizeChanged -= P1DockShell_SizeChanged;
Closed -= P1PanelGeometry_Closed;
_p1PanelGeometry = null;
}
diff --git a/MainWindow.xaml b/MainWindow.xaml
index eb2651a27..d779e5560 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -696,14 +696,16 @@
-
+
@@ -2050,6 +2052,10 @@
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."/>
+