Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions App.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,14 @@
<Setter Property="BorderBrush" Value="#CDD8E5"/>
<Setter Property="MinWidth" Value="56"/>
</Style>
<!-- ctlModel commissioning is a distinct read/configure action, NOT an
Operate/SBO command. Compiled style owns its gear and caption for
every virtualized/recycled row; no Loaded-time visual-tree scan. -->
<Style x:Key="CommandDockCtlModelButton" TargetType="Button" BasedOn="{StaticResource CommandDockSoftButton}">
<Setter Property="ContentTemplate" Value="{StaticResource MaterialSettingsAction}"/>
<Setter Property="MinWidth" Value="88"/>
<Setter Property="Padding" Value="8,3"/>
</Style>
<Style x:Key="CommandOpenButton" TargetType="Button" BasedOn="{StaticResource CommandActionButtonBase}">
<Setter Property="Background" Value="{StaticResource CommandOpenGradient}"/>
<Setter Property="BorderBrush" Value="#69D2A1"/>
Expand Down
9 changes: 5 additions & 4 deletions MainWindow.CommandPanelUx.cs
Original file line number Diff line number Diff line change
Expand Up @@ -215,11 +215,12 @@ private void ConfigureCommandPanelButton(Button button)
// a control has received verified CDC evidence. Do not overwrite its
// IsEnabled binding with ControlSupportsOperate (that would disable
// the first-click preflight and create a dead control row).
if (content is "Open" or "Close" or "Model")
if (content is "Open" or "Close")
{
// Open/Close retain their typed preflight; Model retains the
// connected-session read/write commissioning gate, even when
// ctlModel=0 and physical Operate must remain disabled.
// Position actions retain the typed preflight defined in XAML.
// ctlModel is NOT a command action; its dedicated XAML style
// owns the persistent glyph, and its session-bound Enabled state
// is never rewritten here.
_configuredCommandButtons.Add(button, new Marker());
return;
}
Expand Down
7 changes: 3 additions & 4 deletions MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -821,11 +821,10 @@
<Button Content="Set" Tag="{Binding}" Click="ControlQuickAction_Click" Style="{StaticResource CommandDockPrimaryButton}"
IsEnabled="{Binding ControlSupportsOperate}" Height="29" MinHeight="29" Padding="10,3"/>
</StackPanel>
<Button Grid.Column="1" Content="Model" Tag="{Binding}" Click="ControlDetails_Click"
<Button Grid.Column="1" Content="ctlModel" Tag="{Binding}" Click="ControlDetails_Click"
IsEnabled="{Binding DataContext.SelectedDevice.IsConnected, RelativeSource={RelativeSource AncestorType={x:Type Window}}}"
Style="{StaticResource CommandDockSoftButton}" Margin="6,0,1,0"
MinWidth="48"
Height="29" MinHeight="29" Padding="10,3"/>
Style="{StaticResource CommandDockCtlModelButton}" Margin="6,0,1,0"
Height="29" MinHeight="29"/>
<!-- Unknown/unsupported CDC never produces an empty action cell. -->
<TextBlock Text="No typed action" FontSize="11" Foreground="{StaticResource Muted}" VerticalAlignment="Center">
<TextBlock.Style>
Expand Down
30 changes: 22 additions & 8 deletions Models/MonitorModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -748,16 +748,30 @@ private void RefreshCommandSignals()
.ThenBy(signal => signal.Name, StringComparer.OrdinalIgnoreCase)
.ToArray();

// Stable WPF row lifetime is an architectural invariant: report,
// navigation, and repeated verification updates must never Reset the
// entire operator-action grid when the selected membership is unchanged.
// Keep the same SignalDefinition objects (the control state authority),
// and reconcile only actual membership/order changes. This small
// operator command list deliberately uses granular notifications rather
// than the bulk Reset policy appropriate for high-frequency reports.
// Common case: verification/report/navigation changed data, not the
// selected DataObject membership or its order. O(N) identity check,
// zero WPF collection events, zero row recycling and checkbox flicker.
if (CommandSignals.Count == selected.Length)
{
var same = true;
for (var index = 0; index < selected.Length; index++)
{
if (ReferenceEquals(CommandSignals[index], selected[index])) continue;
same = false;
break;
}
if (same) return;
}

// Reconcile real membership changes by *reference identity* and keep
// the original SignalDefinition instances. The set replaces a repeated
// O(N^2) selected.Any membership scan; the rare ordering correction
// below still emits granular Move/Insert rather than Reset.
var membership = new HashSet<SignalDefinition>(selected,
ReferenceEqualityComparer.Instance);
for (var index = CommandSignals.Count - 1; index >= 0; index--)
{
if (!selected.Any(signal => ReferenceEquals(signal, CommandSignals[index])))
if (!membership.Contains(CommandSignals[index]))
CommandSignals.RemoveAt(index);
}

Expand Down
26 changes: 26 additions & 0 deletions docs/P109AB_CTLMODEL_STATIC_GLYPH_VENDOR_NEUTRAL_20261021.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# ARSAS P109AB — vendor-neutral command catalog and persistent ctlModel glyph (2026-10-21)

## Frozen baseline and cross-thread isolation

- User accepted field evidence ZIVAVR at ARSAS 1.6.40 portable PR #649 exact consumer SHA `4c232184f64584308396ea18ff6c82700ac02d29`, tested by Windows CI #39436937170 (source-tree-identical merge `0c42a3066b90cab4a53abd4ee674d04cb6d89191`; 2,422 consumer/24 engine/6 package tests plus native and portable smoke green).
- Immutable acceptance branch: `checkpoint/arsas-ziv-control-gocb-field-accepted-20261021` at `4c232184f64584308396ea18ff6c82700ac02d29`.
- This phase is stacked on PR #649, **not main**, separate from leaf-data branches/engine repo. No changes to ARIEC61850 or runtime MMS I/O, ctlModel writer, GOOSE, discovery, report, Data Model, transport, control-safety gates or pinned dependency SHAs.

## Technical diagnosis

ZIVAVR five StatusOnly selected CO roots are not vendor-specific exceptions: `Iec61850ControlIdentity.DistinctSelected` normalizes **full DataObject references** and filters `IsSelected && IsValidControlObject` without brand/IP checks. `DistinctOperable`, typed live CDC/ctlModel/status/ctlVal semantic gates, and exact single operator-triggered CF Write/readback remain independent. The same projection works for arbitrary IED domains, e.g. ZIVAVRLD1, SIEBCUCTRL, C264CONTROL.

Field screenshots show the gear present only on some `Model` cells. Cause: `P2IndustrialWorkstationUx.DecorateTextButtons` scanned realized visual tree and assigned `MaterialSettingsAction` DataTemplate for text `Model`; `CommandDataGrid` recycles and virtualizes containers and may realize new rows **after** one-off decorator. That creates inconsistent gears between rows and navigation. Fix removes dependence on the scan for this control: an immutable global `CommandDockCtlModelButton` WPF style owns the `MaterialSettingsAction` template, with the existing shared `MaterialSettingsIcon` path. The button content is `ctlModel`, with no runtime template assembly, no second icon, and static sizing for the larger label. The existing Button.Click and association-based IsEnabled binding are unchanged.

The old `MainWindow.CommandPanelUx` condition `or "Model"` was unreachable because `IsCommandActionButton` excludes Model. Remove the dead branch instead of turning caption checking into an authorization mechanism.

## Optimization without changing command behavior

`Iec61850MonitorDevice.RefreshCommandSignals` retains exact selected identity and incremental reconciliation. The hot **unchanged-membership** case now performs an O(N) reference-identity compare and immediately returns: no per-row `.Any()`, no DataGrid collection notifications, no resets and no UI row churn. Membership removal on actual edits uses a reference-identity HashSet instead of O(N²) repeated scans. The rare reorder pass still performs minimal granular Move/Insert; no wholesale ReplaceAll/Reset. This is a consumer presentation optimization, not an engine rewrite.

## Regression / acceptance contract

- Declarative model action must be exactly `ctlModel`, visually one 14px settings gear beside the caption on every recycled row; same appearance when scrolling / switching tabs / changing IED and at 100%-150% DPI. Template is authored statically, not in Loaded-time decoration.
- WPF ControlDetails_Click still opens the same exact live ctlModel editor only on connected IED. A Stage/Operate/SBO remains fail-closed unless typed runtime authority permits it; read-only/status-only remains visible, not physically operable.
- Large (240 selected controls) identical projection repeated 40x has zero collection notifications and preserves object instances; genuine selection change uses targeted removals, never Reset. Three different domain prefixes all work the same with no vendor exception.
- Exact head Windows CI (full app, pinned engine and native bridge tests, packaged portable smoke) before artifact handoff. **No main merge/public release** until operator confirms visuals/field safety; no claim about physical command from CI.
5 changes: 3 additions & 2 deletions tests/ARSAS.Tests/P109AAZivCommandAndGoCbCardTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,9 @@ string Read(string name)
var models = Read("Models/MonitorModels.cs");
Assert.Contains("DistinctSelected(Signals)",models,StringComparison.Ordinal);
Assert.DoesNotContain("DistinctOperable(Signals)",models,StringComparison.Ordinal);
Assert.Contains("if (content is \"Open\" or \"Close\" or \"Model\")",ux,StringComparison.Ordinal);
Assert.Contains("Content=\"Model\"",xaml,StringComparison.Ordinal);
Assert.Contains("if (content is \"Open\" or \"Close\")",ux,StringComparison.Ordinal);
Assert.Contains("Content=\"ctlModel\"",xaml,StringComparison.Ordinal);
Assert.Contains("Style=\"{StaticResource CommandDockCtlModelButton}\"",xaml,StringComparison.Ordinal);
Assert.Contains("IsEnabled=\"{Binding ControlSupportsOperate}\"",xaml,StringComparison.Ordinal);
Assert.Contains("hasGoose ? CapabilityState.Available : CapabilityState.Unavailable",cap,StringComparison.Ordinal);
Assert.Contains("var hasGoose = device.HasGooseControlBlock;",cap,StringComparison.Ordinal);
Expand Down
163 changes: 163 additions & 0 deletions tests/ARSAS.Tests/P109ABCtlModelGlyphAndVendorNeutralCatalogTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
using System.Collections.Specialized;
using System.Xml.Linq;
using ArIED61850Tester.Models;
using ArIED61850Tester.Services;

namespace ARSAS.Tests;

/// <summary>
/// A WPF recycled Command Dock row must have its own declarative ctlModel
/// gear+label; a best-effort Loaded-time material decorator cannot own the icon.
/// The selected control catalog is IEC DataObject based, never IED-vendor based.
/// </summary>
public sealed class P109ABCtlModelGlyphAndVendorNeutralCatalogTests
{
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";

private static string Source(string file)
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
{
var path = Path.Combine(dir.FullName, file);
if (File.Exists(path)) return File.ReadAllText(path);
}
throw new FileNotFoundException(file);
}

[Fact]
public void EveryVirtualizedRowGetsAStaticSingleOwnerSettingsGlyphAndCtlModelLabel()
{
var window = XDocument.Parse(Source("MainWindow.xaml"));
var mainGrid = window.Descendants(P + "DataGrid").Single(g =>
(string?)g.Attribute("ItemsSource") == "{Binding SelectedDevice.CommandSignals}");
Assert.Equal("Recycling", (string?)mainGrid.Attribute("VirtualizingPanel.VirtualizationMode"));
var modelButtons = mainGrid.Descendants(P + "Button").Where(b =>
(string?)b.Attribute("Click") == "ControlDetails_Click").ToArray();
var button = Assert.Single(modelButtons);
Assert.Equal("ctlModel", (string?)button.Attribute("Content"));
Assert.Equal("{StaticResource CommandDockCtlModelButton}", (string?)button.Attribute("Style"));
Assert.Equal("{Binding}", (string?)button.Attribute("Tag"));
Assert.Contains("SelectedDevice.IsConnected",
(string?)button.Attribute("IsEnabled"), StringComparison.Ordinal);
Assert.Null(button.Attribute("ContentTemplate")); // owned by the immutable style
Assert.Empty(button.Elements(P + "Button.Content")); // no added visual child per row

var app = XDocument.Parse(Source("App.xaml"));
var styles = app.Descendants(P + "Style").ToArray();
var modelStyle = Assert.Single(styles.Where(s =>
(string?)s.Attribute(X + "Key") == "CommandDockCtlModelButton"));
Assert.Equal("{StaticResource CommandDockSoftButton}", (string?)modelStyle.Attribute("BasedOn"));
var templateSetter = Assert.Single(modelStyle.Elements(P + "Setter").Where(s =>
(string?)s.Attribute("Property") == "ContentTemplate"));
Assert.Equal("{StaticResource MaterialSettingsAction}",
(string?)templateSetter.Attribute("Value"));
Assert.Contains(modelStyle.Elements(P + "Setter"), s =>
(string?)s.Attribute("Property") == "MinWidth" &&
(string?)s.Attribute("Value") == "88");
var template = Assert.Single(app.Descendants(P + "DataTemplate").Where(t =>
(string?)t.Attribute(X + "Key") == "MaterialSettingsAction"));
var icon = Assert.Single(template.Descendants(P + "Path"));
Assert.Equal("{StaticResource MaterialSettingsIcon}", (string?)icon.Attribute("Data"));
Assert.Contains("AncestorType", (string?)icon.Attribute("Fill"), StringComparison.Ordinal);
Assert.Equal("{Binding}", (string?)Assert.Single(template.Descendants(P + "TextBlock")).Attribute("Text"));
Assert.Single(app.Descendants(P + "Geometry").Where(g =>
(string?)g.Attribute(X + "Key") == "MaterialSettingsIcon"));

// The historical Loaded/layout pass only recognizes the old "Model"
// caption. The compiled ctlModel button no longer depends on its
// window-wide visual-tree scan and can survive WPF row recycling.
var decorator = Source("P2IndustrialWorkstationUx.cs");
Assert.Contains("else if (text is \"Model\")", decorator, StringComparison.Ordinal);
Assert.DoesNotContain("else if (text is \"ctlModel\")", decorator, StringComparison.Ordinal);
Assert.Contains("Style=\"{StaticResource CommandDockCtlModelButton}\"", Source("MainWindow.xaml"), StringComparison.Ordinal);
}

[Fact]
public void HundredsOfUnchangedControlsNeedNoResetMoveOrReplaceNotifications()
{
var device = new Iec61850MonitorDevice();
var selected = Enumerable.Range(1, 240).Select(i => new SignalDefinition
{
Name = "Pos",
ObjectReference = $"LD0/CSWI{i}.Pos",
FunctionalConstraint = "CO",
IsControlSignal = true,
IsSelected = true,
ControlCdc = "DPC",
ControlModelText = "Status only"
}).ToArray();
device.Signals.ReplaceAll(selected);
device.RecountSelectedSignals();
Assert.Equal(selected.Length, device.CommandSignals.Count);
var frozen = device.CommandSignals.ToArray();
var notifications = new List<NotifyCollectionChangedAction>();
device.CommandSignals.CollectionChanged += (_, e) => notifications.Add(e.Action);

// No IED I/O. Repeated model verification and tab revisits must be
// an identity-only fast path with stable WPF container lifetime.
for (var attempt = 0; attempt < 40; attempt++)
device.RefreshCommandSignalProjection();
Assert.Empty(notifications);
Assert.True(frozen.SequenceEqual(device.CommandSignals, ReferenceEqualityComparer.Instance));

selected[3].IsSelected = false;
device.RecountSelectedSignals();
Assert.DoesNotContain(selected[3], device.CommandSignals);
Assert.DoesNotContain(NotifyCollectionChangedAction.Reset, notifications);
Assert.Equal(239, device.CommandSignals.Count);
}

[Fact]
public void VendorIndependentSelectedRootsRemainStableWhileWritePermissionChanges()
{
var devices = new[]
{
new Iec61850MonitorDevice { Name = "ZIVAVR" },
new Iec61850MonitorDevice { Name = "SIEBCU" },
new Iec61850MonitorDevice { Name = "GE_C264" }
};
var references = new[]
{
"ZIVAVRLD1/CSWI1.Pos",
"SIEBCUCTRL/CSWI1.Pos",
"C264CONTROL/CSWI1.Pos"
};

for (var i = 0; i < devices.Length; i++)
{
var row = new SignalDefinition
{
ObjectReference = references[i],
Name = "Pos",
FunctionalConstraint = "CO",
IsControlSignal = true,
IsSelected = true,
ControlCdc = "DPC",
ControlModelText = "Status only"
};
var decoyStatus = new SignalDefinition
{
ObjectReference = references[i] + ".stVal",
FunctionalConstraint = "ST",
IsSelected = true,
IsControlSignal = false
};
devices[i].Signals.ReplaceAll([row, decoyStatus]);
devices[i].RecountSelectedSignals();
Assert.Same(row, Assert.Single(devices[i].CommandSignals));
Assert.False(row.ControlSupportsOperate);
Assert.Empty(Iec61850ControlIdentity.DistinctOperable(devices[i].Signals));

var changes = new List<NotifyCollectionChangedAction>();
devices[i].CommandSignals.CollectionChanged += (_, e) => changes.Add(e.Action);
// A new ctlModel interpretation is not a change in DataObject
// identity, so neither virtualization nor checkbox state resets.
row.ControlModelText = "SBO • Enhanced security";
for (var j = 0; j < 50; j++) devices[i].RefreshCommandSignalProjection();
Assert.Empty(changes);
Assert.Same(row, Assert.Single(devices[i].CommandSignals));
Assert.False(row.ControlSupportsOperate); // typed proof still absent
}
}
}
3 changes: 2 additions & 1 deletion tests/ARSAS.Tests/P109NControlAuthorityTimelineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@ public void CompactDockKeepsFullLiveAreaAndActionButtonsWithinRows()
Assert.DoesNotContain("ExplorerLiveRow.Height", code);
Assert.Contains("RowHeight=\"43\" MinRowHeight=\"42\"", xaml);
Assert.Contains("Height=\"29\" MinHeight=\"29\" Padding=\"10,3\"", xaml);
Assert.Contains("Content=\"Model\"", xaml);
Assert.Contains("Content=\"ctlModel\"", xaml);
Assert.Contains("Style=\"{StaticResource CommandDockCtlModelButton}\"", xaml);
Assert.Contains("CommandParameter=\"Raise\" Click=\"ControlStageAction_Click\"", xaml);
Assert.Contains("CommandParameter=\"Lower\" Click=\"ControlStageAction_Click\"", xaml);
}
Expand Down
Loading
Loading