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
2 changes: 2 additions & 0 deletions MainWindow.GooseSubscriber.cs
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,8 @@ private void GooseSubscriberRuntime_StatusChanged(GooseSubscriberStatusSnapshot
Dispatcher.BeginInvoke(new Action(() =>
{
IsGooseCapturing = status.IsRunning;
if (MainTabs.SelectedIndex == IedBrowserWorkspaceIndex)
RefreshIedBrowserReadButtons();
GooseCapturedFrames = status.CapturedFrames;
GooseFrames = status.GooseFrames;
GooseOtherFrames = status.OtherFrames;
Expand Down
147 changes: 137 additions & 10 deletions MainWindow.IedBrowser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -472,10 +472,43 @@ await _runtime.ReadBrowserValuesAsync(device, targets,
private void IedBrowserGooseSubscribe_Click(object sender, RoutedEventArgs e)
{
if (SelectedDevice is not { IsDemo: false } device ||
_iedBrowserRenderedPage?.Kind != "GoCB")
_iedBrowserRenderedPage?.Kind != "GoCB" || GooseActionBusy)
return;
// Shared Npcap-based subscriber, no second GOOSE receive/decoder path.
OpenIedGooseSubscriber(device);
// The Browser is an independent view of the SAME Npcap subscriber;
// never navigate to the Sniff tab or instantiate another receiver.
if (IsGooseCapturing)
{
UpdateIedBrowserGooseDetails();
return;
}
if (GooseAdapters.Count == 0)
RefreshGooseAdapters();
if (SelectedGooseAdapter is null)
{
var proposed = ResolveGooseAdapterForIed(device, out var route);
if (proposed is not null)
SelectedGooseAdapter = proposed;
else
IedBrowserDetailStatus.Text =
"Select the station capture adapter here, then press Subscribe GOOSE. " + route;
}
IedBrowserGooseAdapterCombo.Visibility = Visibility.Visible;
if (SelectedGooseAdapter is null)
{
IedBrowserGooseAdapterCombo.Focus();
RefreshIedBrowserReadButtons();
return;
}
// Existing start path owns decoder, bound model, lifetime and UI queue.
StartGooseSubscriber_Click(sender, e);
RefreshIedBrowserReadButtons();
}

private async void IedBrowserGooseStop_Click(object sender, RoutedEventArgs e)
{
if (!IsGooseCapturing || GooseActionBusy) return;
await StopGooseSubscriberAsync();
RefreshIedBrowserReadButtons();
}

private void RefreshIedBrowserReadButtons()
Expand All @@ -499,7 +532,18 @@ private void RefreshIedBrowserReadButtons()
IedBrowserGooseSubscribeButton.IsEnabled =
device is { IsDemo: false } && _iedBrowserReadOperation == null;
IedBrowserGooseSubscribeButton.Content = IsGooseCapturing
? "View GOOSE Capture" : "Subscribe GOOSE";
? "View live GOOSE" : "Subscribe GOOSE";
IedBrowserGooseSubscribeButton.IsEnabled =
device is { IsDemo: false } && !GooseActionBusy;
IedBrowserGooseStopButton.Visibility =
_iedBrowserRenderedPage?.Kind == "GoCB" && IsGooseCapturing
? Visibility.Visible : Visibility.Collapsed;
IedBrowserGooseStopButton.IsEnabled = !GooseActionBusy;
IedBrowserGooseAdapterCombo.Visibility =
_iedBrowserRenderedPage?.Kind == "GoCB" &&
(!IsGooseCapturing || GooseAdapters.Count > 0)
? Visibility.Visible : Visibility.Collapsed;
IedBrowserGooseAdapterCombo.IsEnabled = !IsGooseCapturing && !GooseActionBusy;
}
IedBrowserReadPageButton.Content = selectedKind == IedBrowserReadSelectionKind.ReadOnlyRcb
? "Inspect RCB"
Expand Down Expand Up @@ -945,7 +989,17 @@ void Put(string name, string value, string authority)
.FirstOrDefault(x => ReferenceEquals(frameView, x.Display)).Raw;
if (frameView is not null && received is not null)
{
Put("Capture status", "Received · exact GoCB verified", "Passive GOOSE");
Put("Capture status",
string.IsNullOrWhiteSpace(frameView.ModelIedName)
? "Receiving · exact wire GoCB, publisher model unbound"
: "Receiving · exact GoCB and publisher model matched",
"Passive GOOSE; never implies MMS state or publisher authentication");
Put("GOOSE ID", frameView.GoId, "Received GOOSE PDU GoID");
Put("DataSet reference", frameView.DataSetReference,
"Received GOOSE PDU datSet");
Put("Application ID", frameView.AppIdText, "Received Ethernet APPID");
Put("Configuration revision", frameView.ConfigurationRevisionText,
"Received GOOSE PDU confRev");
Put("Frame received at", received.CaptureTimestamp.ToLocalTime()
.ToString("yyyy-MM-dd HH:mm:ss.fff zzz",
System.Globalization.CultureInfo.InvariantCulture), "Passive capture time");
Expand All @@ -962,13 +1016,83 @@ void Put(string name, string value, string authority)
"Received timeAllowedToLive");
Put("Frame DataSet entries", frameView.WireDataSetEntryCount.ToString(
System.Globalization.CultureInfo.InvariantCulture), "Received allData");
// Some devices expose GO/GoEna but omit GO/DatSet in the MMS
// control inventory. Once a full matching wire GoCB is received,
// resolve its *exact wire datSet* against the selected IED model.
// Do not infer a link from GOOSE ID or the number of entries.
if (_iedBrowserGooseDirectory is null &&
_iedBrowserRenderedPage?.Payload is LiveIedControlBlockModel block &&
SelectedDevice is { } selected)
{
var wireSet = IedBrowserGooseDirectory.ResolveWireDataSet(
selected.LiveDiscoveryModel, selected.SclWorkspace?.DesignModel,
block.Domain, frameView.DataSetReference);
if (wireSet is { } resolved)
_iedBrowserGooseDirectory = resolved.Set;
}
var ordered = _iedBrowserGooseDirectory is null
? null : IedBrowserGooseInspection.VerifiedMembers(frameView,
_iedBrowserGooseDirectory);

// Append only when the member shape changes. Existing flat
// virtualized rows are updated in place each UI coalescing tick;
// never rebuild the tree or mirror wire values into MMS LiveValues.
var wireSlots = _iedBrowserDetails.Rows
.Where(row => row.GooseSlot >= 0 && row.GooseReference.Length == 0)
.ToArray();
var limit = Math.Min(frameView.Leaves.Count, 128);
if (ordered is not null)
{
foreach (var slot in wireSlots)
_iedBrowserDetails.Rows.Remove(slot);
// The model directory may have become available only after
// the wire datSet was observed. Attach the exact FCDA rows
// once without replacing the whole virtualized Browser view.
if (!_iedBrowserDetails.Rows.Any(row =>
row.GooseSlot >= 0 && row.GooseReference.Length != 0) &&
_iedBrowserGooseDirectory is { } directory &&
SelectedDevice is { } owner)
{
var descriptors = directory.Members.OrderBy(m => m.Index).ToArray();
var sourceModel = owner.LiveDiscoveryModel ??
owner.SclWorkspace?.DesignModel;
for (var slot = 0; slot < descriptors.Length; slot++)
{
var member = descriptors[slot];
_iedBrowserDetails.Rows.Add(
IedBrowserStructuredDetails.Member(member, sourceModel) with
{
GooseSlot = slot, GooseReference = member.Reference,
GooseFc = member.FunctionalConstraint
});
}
}
}
else if (wireSlots.Length != limit)
{
foreach (var slot in wireSlots)
_iedBrowserDetails.Rows.Remove(slot);
for (var slot = 0; slot < limit; slot++)
_iedBrowserDetails.Rows.Add(new IedBrowserDetailRow(
"Data", $"allData[{slot + 1}] · unbound", "Not received",
Source: "GOOSE received wire order; FCDA is not verified")
{
GooseSlot = slot
});
}
foreach (var row in _iedBrowserDetails.Rows)
{
if (row.GooseSlot < 0) continue;
if (ordered is not null && row.GooseSlot < ordered.Count &&
if (row.GooseReference.Length == 0 &&
ordered is null && row.GooseSlot < frameView.Leaves.Count)
{
row.Value = frameView.Leaves[row.GooseSlot].Value;
row.ObservationSource = "GOOSE raw allData · unbound";
row.ObservationTooltip =
$"allData[{row.GooseSlot + 1}] from {frameView.GoCbRef} " +
$"· received {received.CaptureTimestamp:O}; no FCDA claim";
}
else if (ordered is not null && row.GooseSlot < ordered.Count &&
IedBrowserGooseInspection.Canonical(row.GooseReference) ==
IedBrowserGooseInspection.Canonical(ordered[row.GooseSlot].SignalReference) &&
string.Equals(row.GooseFc, ordered[row.GooseSlot].FunctionalConstraint,
Expand All @@ -980,9 +1104,10 @@ void Put(string name, string value, string authority)
"] · passive received " + received.CaptureTimestamp.ToString("O",
System.Globalization.CultureInfo.InvariantCulture);
}
else if (row.ObservationSource == "GOOSE frame")
else if (row.ObservationSource is "GOOSE frame" or
"GOOSE raw allData · unbound")
{
row.Value = "Not read";
row.Value = "Not current";
row.ObservationSource = "";
row.ObservationTooltip = "";
}
Expand All @@ -998,8 +1123,10 @@ void Put(string name, string value, string authority)
"Passive capture · exact identity and received TTL required; not an MMS GoCB read");
foreach (var row in _iedBrowserDetails.Rows)
{
if (row.GooseSlot < 0 || row.ObservationSource != "GOOSE frame") continue;
row.Value = "Not read";
if (row.GooseSlot < 0 ||
row.ObservationSource is not ("GOOSE frame" or
"GOOSE raw allData · unbound")) continue;
row.Value = "Not current";
row.ObservationSource = "";
row.ObservationTooltip = "";
}
Expand Down
12 changes: 11 additions & 1 deletion MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -1991,10 +1991,20 @@
Style="{StaticResource SoftButton}" Margin="0,0,6,0"
Click="IedBrowserReadValues_Click"
ToolTip="Read model-proven IEC data attributes sequentially; progress appears on the IED card."/>
<ComboBox x:Name="IedBrowserGooseAdapterCombo"
Width="195" Height="30" Margin="0,0,5,0"
ItemsSource="{Binding GooseAdapters}"
SelectedItem="{Binding SelectedGooseAdapter, Mode=TwoWay}"
DisplayMemberPath="DisplayText" Visibility="Collapsed"
ToolTip="Station Ethernet capture adapter. Choosing an adapter never starts capture automatically."/>
<Button x:Name="IedBrowserGooseStopButton" Content="Stop GOOSE"
Style="{StaticResource SoftButton}" Margin="0,0,5,0"
Visibility="Collapsed" Click="IedBrowserGooseStop_Click"
ToolTip="Stop the existing passive GOOSE subscriber; no MMS writes."/>
<Button x:Name="IedBrowserGooseSubscribeButton" Content="Subscribe GOOSE"
Style="{StaticResource SoftButton}" Margin="0,0,6,0"
Visibility="Collapsed" Click="IedBrowserGooseSubscribe_Click"
ToolTip="Open existing passive GOOSE Subscriber for this IED; choose a capture adapter. Does not enable GoEna or write MMS."/>
ToolTip="Start or inspect the existing read-only subscriber here in IED Browser, without switching workspace or writing MMS."/>
<Button x:Name="IedBrowserCancelReadButton" Content="Cancel Read"
Style="{StaticResource SoftButton}" Margin="0,0,6,0"
IsEnabled="False" Click="IedBrowserCancelRead_Click"
Expand Down
34 changes: 34 additions & 0 deletions Models/IedBrowserGooseDirectory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,40 @@ public static IReadOnlyList<LiveIedControlBlockModel> Resolve(
return blocks;
}

/// <summary>
/// Recover a directory ONLY after a matching received GoCB supplies an
/// actual wire DataSet identity. Offline SCL may enrich that exact IED
/// domain, but no DataSet is guessed from the GoCB name or array length.
/// Reject duplicate definitions rather than choosing the first.
/// </summary>
public static (LiveIedModelDiscoveryDocument Model, LiveIedDataSetModel Set)?
ResolveWireDataSet(LiveIedModelDiscoveryDocument? live,
LiveIedModelDiscoveryDocument? scl, string domain, string wireReference)
{
if (string.IsNullOrWhiteSpace(domain) ||
string.IsNullOrWhiteSpace(wireReference))
return null;
static LiveIedDataSetModel? Exact(
LiveIedModelDiscoveryDocument? source, string domain, string reference)
{
var matches = source?.DataSets.Where(set =>
string.Equals(set.Domain, domain, StringComparison.Ordinal) &&
string.Equals(IedBrowserGooseInspection.Canonical(set.Reference),
IedBrowserGooseInspection.Canonical(reference),
StringComparison.Ordinal)).Take(2).ToArray();
return matches is { Length: 1 } ? matches[0] : null;
}
if (Exact(live, domain, wireReference) is { } verified)
return (live!, verified);
// SCL is a directory, not wire evidence. Match only a domain present
// in the selected device's live discovery (or an offline SCL session).
if (live is not null &&
!live.LogicalDevices.Any(ld => ld.MmsDomain == domain))
return null;
return Exact(scl, domain, wireReference) is { } fallback
? (scl!, fallback) : null;
}

public static LiveIedModelDiscoveryDocument? ModelForDataSet(
LiveIedModelDiscoveryDocument? live,
LiveIedModelDiscoveryDocument? scl,
Expand Down
15 changes: 11 additions & 4 deletions Models/IedBrowserGooseInspection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,21 @@ public static bool IsFrameCurrent(
ArgumentNullException.ThrowIfNull(candidates);
var control = Canonical(expected.GoCbRef);
var dataSet = Canonical(expected.DataSetReference);
if (control.Length == 0 || dataSet.Length == 0 ||
string.IsNullOrWhiteSpace(expected.IedName)) return null;
// GoCB full wire reference is the mandatory authority. Some IEDs
// expose GO controls but not their DatSet attributes via MMS; a
// received, exact GoCB can still be inspected in *unbound allData*
// order. Never infer an FCDA reference from its position.
if (control.Length == 0 || !control.Contains('/'))
return null;

// Two publishers with the same GoCb identity are ambiguous, never last-one-wins.
var matches = candidates.Where(frame =>
string.Equals(frame.ModelIedName, expected.IedName, StringComparison.Ordinal) &&
(string.IsNullOrEmpty(frame.ModelIedName) ||
string.Equals(frame.ModelIedName, expected.IedName, StringComparison.Ordinal)) &&
string.Equals(Canonical(frame.GoCbRef), control, StringComparison.Ordinal) &&
string.Equals(Canonical(frame.DataSetReference), dataSet, StringComparison.Ordinal) &&
(dataSet.Length == 0 ||
string.Equals(Canonical(frame.DataSetReference), dataSet,
StringComparison.Ordinal)) &&
(string.IsNullOrWhiteSpace(expected.GoId) ||
string.Equals(frame.GoId, expected.GoId, StringComparison.Ordinal)) &&
(!expected.AppId.HasValue || AppId(frame.AppIdText) == expected.AppId.Value) &&
Expand Down
2 changes: 1 addition & 1 deletion Models/IedBrowserPresentation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@
string? iedName = null,
LiveIedModelDiscoveryDocument? sclModel = null)
{
const int childDepth = 1;

Check warning on line 321 in Models/IedBrowserPresentation.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

The variable 'childDepth' is assigned but its value is never used

Check warning on line 321 in Models/IedBrowserPresentation.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

The variable 'childDepth' is assigned but its value is never used

Check warning on line 321 in Models/IedBrowserPresentation.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

The variable 'childDepth' is assigned but its value is never used

Check warning on line 321 in Models/IedBrowserPresentation.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

The variable 'childDepth' is assigned but its value is never used

Check warning on line 321 in Models/IedBrowserPresentation.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

The variable 'childDepth' is assigned but its value is never used

Check warning on line 321 in Models/IedBrowserPresentation.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

The variable 'childDepth' is assigned but its value is never used
// Keep domain/object keys untouched; only presentation labels are short.
var labelIed = !string.IsNullOrWhiteSpace(model?.IedName)
? model.IedName : iedName;
Expand Down Expand Up @@ -727,7 +727,7 @@
IedBrowserGooseDirectory.ModelForDataSet(model, sclModel, block);
var linkedGooseSet = FindDataSet(gooseDirectoryModel, block.DataSetReference);
if (linkedGooseSet is null)
rows.Add(new("Message", "No exact DataSet member directory for this GoCB in selected IED models."));
rows.Add(new("Message", "No exact FCDA directory yet. A matching GOOSE frame will display its received allData slots without guessed signal names."));
else
{
var ordered = linkedGooseSet.Members.OrderBy(m => m.Index).ToArray();
Expand Down
Loading
Loading