Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
da00c2f
fix(connect): classify bounded transient retry for initial IEC 61850 …
masarray Nov 5, 2026
5760d08
fix(connect): bounded retry after transient initial association failu…
masarray Nov 5, 2026
97041f4
fix(ui): render exact MMS two-bit status as meaningful significant bi…
masarray Nov 5, 2026
98a507b
test(P8): regression for two-bit MMS presentation and safe first-conn…
masarray Nov 5, 2026
e9cd9e5
test(P8): regression for two-bit MMS presentation and safe first-conn…
masarray Nov 5, 2026
c99791c
fix(connect): distinguish internal association cancellation from user…
masarray Nov 5, 2026
6306d05
test(connect): bounded native deadline retry remains distinct from us…
masarray Nov 5, 2026
61e3c10
test(connect): bounded native deadline retry remains distinct from us…
masarray Nov 5, 2026
0d4e389
fix(diagnostics): use exact per-RCB frame proof to prevent false no-r…
masarray Nov 5, 2026
6b32145
test(ui): distinguish raw two-bit process values and exact RCB frame …
masarray Nov 5, 2026
bf9a500
test(ui): distinguish raw two-bit process values and exact RCB frame …
masarray Nov 5, 2026
9e1564e
test(ui): preserve metadata-only BIT badge for true MMS bit-string le…
masarray Nov 5, 2026
91f52ad
docs(P8): record SIEBCU physical evidence, bounded transport retry an…
masarray Nov 5, 2026
377bda2
fix(ui): never paint generic BIT STRING 10 as DPC closed/active; add …
masarray Nov 5, 2026
240716f
fix(P8): decode two-bit display only for exact status stVal; keep raw…
masarray Nov 5, 2026
fe265e9
test(P72): assert per-RCB ingress evidence instead of obsolete UI fir…
masarray Nov 5, 2026
7978949
test(P8): explicitly lock raw measurement, q and non-status bit-strin…
masarray Nov 5, 2026
f998088
refactor(P8): restore canonical formatter and isolate metadata-safe t…
masarray Nov 5, 2026
c7b7c68
refactor(connect): single association recovery executor for discovery…
masarray Nov 5, 2026
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
17 changes: 13 additions & 4 deletions MainWindow.IoTesting.AutoConnect.cs
Original file line number Diff line number Diff line change
Expand Up @@ -524,23 +524,32 @@ await Task.Delay(remaining, _applicationCancellation.Token)
// This observer never drives MMS IO, GI or a cyclic fallback.
LogStaticRcbImageTriage(device);

if (trafficObserved && device.HasReportStream)
// The per-RCB decoder-backed stream evidence is authoritative.
// The UI pulse can be false (e.g. event lost at monitor startup)
// even after all seven RCBs delivered genuine InformationReports.
var exactRcbRows = ArIED61850Tester.Services.StaticDataSetRcbImageTriage.Evaluate(
device.Points, device.ReportContinuityEvidence, maxGroups: 16);
var routedRcbs = exactRcbRows.Count(row => row.HasActualReportFrames);
if (routedRcbs > 0)
{
var reportPoints = device.Points.Count(point =>
IsReportSource(point.SourceMode) &&
!point.SourceMode.Contains("pending", StringComparison.OrdinalIgnoreCase));
var firstReportLatency = DateTime.UtcNow - startedUtc;
AddLog(
"INFO",
device.Name,
$"Static DataSet report evidence: actual InformationReport traffic observed after {firstReportLatency.TotalMilliseconds:0} ms; report-backed runtime point(s)={reportPoints}. At least one RCB delivered a frame; each other RCB remains independently unverified until its actual ingress is observed.");
$"Static DataSet report evidence: actual InformationReport traffic observed within {proofWindow.TotalSeconds:0.#} s; " +
$"exact routed RCBs={routedRcbs}/{exactRcbRows.Count}; report-backed runtime point(s)={reportPoints}. " +
"Each RCB remains independently verified by its own frames; zero-frame peers are not assumed healthy.");
return;
}

AddLog(
"WARN",
device.Name,
$"Static DataSet report evidence: configured RCB setup/GI was requested, but no InformationReport traffic was observed within {proofWindow.TotalSeconds:0.#} s. Activation remains armed/report-pending; absence of traffic alone is not treated as RCB activation failure and ARSAS will NOT switch process values to cyclic MMS polling.");
$"Static DataSet report evidence: no exact routed RCB InformationReport traffic was observed within {proofWindow.TotalSeconds:0.#} s " +
$"(uiPulse={trafficObserved && device.HasReportStream}, configuredGroups={exactRcbRows.Count}). " +
"Activation remains armed/report-pending; lack of exact routed ingress is not proof of IED silence and ARSAS will NOT switch process values to cyclic MMS polling.");
MarkDiagnosticAlert();
}
catch (OperationCanceledException)
Expand Down
8 changes: 8 additions & 0 deletions Models/Iec61850ValueStatePresentation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ public static string Classify(string? value, string? dataType = null)

var normalized = text.ToLowerInvariant();

// A raw typed BIT STRING's bits are data, not a DPC/Boolean assertion.
// In particular Bits [10] must not inherit the DPC Closed/Active tone.
if (normalized.StartsWith("bits [", StringComparison.Ordinal))
return Neutral;

// DPC state codes are process states, not health/severity. Intermediate and
// bad are the only forms that deserve the amber attention tone.
if (normalized.Contains("intermediate", StringComparison.Ordinal) ||
Expand Down Expand Up @@ -96,6 +101,9 @@ public static string TypeToken(string? dataType)
return "F";
}

if (normalized is "BITSTRING" or "BITS")
return "BIT";

if (normalized.StartsWith("UINT", StringComparison.Ordinal) ||
normalized is "INT8U" or "INT16U" or "INT24U" or "INT32U" or "INT64U")
{
Expand Down
91 changes: 65 additions & 26 deletions Services/Iec61850MonitorRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,8 @@
try
{
progress?.Report(new IedDiscoveryProgress(IedDiscoveryStage.OpeningTcp, $"Opening TCP {device.IpAddress}:{device.Port}…", 8d, 2, 15));
await session.Client.ConnectAsync(device.IpAddress, device.Port, cancellationToken).ConfigureAwait(false);
await ConnectInitialDiscoveryAssociationWithRetryAsync(
session, cancellationToken).ConfigureAwait(false);
if (!session.Client.IsConnected)
{
device.Status = "Connection failed";
Expand Down Expand Up @@ -2096,7 +2097,7 @@
{
if (update.HasValue &&
IedBrowserReportValueAuthority.SafeScalar(update.Value) &&
!LooksLikeReferenceEcho(update.Value, update.Reference, update.Reference))

Check warning on line 2100 in Services/Iec61850MonitorRuntime.cs

View workflow job for this annotation

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

Possible null reference argument for parameter 'value' in 'bool Iec61850MonitorRuntime.LooksLikeReferenceEcho(string value, string updateReference, string pointReference)'.

Check warning on line 2100 in Services/Iec61850MonitorRuntime.cs

View workflow job for this annotation

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

Possible null reference argument for parameter 'value' in 'bool Iec61850MonitorRuntime.LooksLikeReferenceEcho(string value, string updateReference, string pointReference)'.

Check warning on line 2100 in Services/Iec61850MonitorRuntime.cs

View workflow job for this annotation

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

Possible null reference argument for parameter 'value' in 'bool Iec61850MonitorRuntime.LooksLikeReferenceEcho(string value, string updateReference, string pointReference)'.
session.Device.LiveValues.PublishForAssociation(
session.BrowserObservationAssociation,
update.Reference, reportFc, update.Value,
Expand Down Expand Up @@ -3036,55 +3037,93 @@
});
}

private async Task ConnectCachedAssociationWithRetryAsync(
DeviceSession session,
CancellationToken cancellationToken)
// Both initial full discovery and cached reconnect use ONE association
// executor; only their bounded per-attempt timeout differs. Reporting,
// discovery and control operations stay strictly outside this lifecycle.
private Task ConnectInitialDiscoveryAssociationWithRetryAsync(
DeviceSession session, CancellationToken cancellationToken)
=> ConnectAssociationWithRetryAsync(session, cachedModel: false, cancellationToken);

private Task ConnectCachedAssociationWithRetryAsync(
DeviceSession session, CancellationToken cancellationToken)
=> ConnectAssociationWithRetryAsync(session, cachedModel: true, cancellationToken);

private async Task ConnectAssociationWithRetryAsync(
DeviceSession session, bool cachedModel, CancellationToken cancellationToken)
{
const int maxAttempts = 2;
for (var attempt = 1; attempt <= maxAttempts; attempt++)
var phase = cachedModel ? "Fast saved-model" : "Initial discovery";
for (var attempt = 1;
attempt <= SmartReconnectPolicy.InitialDiscoveryAssociationMaxAttempts;
attempt++)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(SmartReconnectPolicy.ConnectBudget);
cancellationToken.ThrowIfCancellationRequested();

// Cached association already has a strict two-second attempt budget.
// Initial discovery retains the engine's own eight-second budget.
using var budget = cachedModel
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)
: null;
if (budget is not null)
budget.CancelAfter(SmartReconnectPolicy.ConnectBudget);
var attemptToken = budget?.Token ?? cancellationToken;

var started = Stopwatch.GetTimestamp();
var nativeDeadlineCancelled = false;
try
{
await session.Client.ConnectAsync(
session.Device.IpAddress,
session.Device.Port,
timeout.Token).ConfigureAwait(false);
attemptToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
session.Device.LastDiagnosticSnapshot = session.Client.CaptureDiagnosticSnapshot(
$"Fast saved-model connect attempt #{attempt} timed out");
// Only the attempt-local budget or native internal timeout.
// Explicit user cancellation still propagates and is not retried.
nativeDeadlineCancelled = true;
}

var elapsed = Stopwatch.GetElapsedTime(started);
if (session.Client.IsConnected)
{
if (attempt > 1)
{
Log("INFO", session.Device.Name,
$"Fast saved-model connection recovered automatically on attempt #{attempt}; no manual Play retry was required.");
}
$"{phase} MMS connection recovered on attempt #{attempt} " +
$"after {elapsed.TotalMilliseconds:0} ms; discovery and RCB setup remain single-flight.");
return;
}

if (attempt >= maxAttempts)
return;
var failureKind = nativeDeadlineCancelled
? "NATIVE_ASSOCIATION_CANCELLED"
: session.Client.LastConnectionFailureKind;
var failureDetail = nativeDeadlineCancelled
? "Association attempt deadline expired before MMS was ready."
: string.IsNullOrWhiteSpace(session.Client.LastErrorMessage)
? $"native state={session.Client.NativeState}"
: session.Client.LastErrorMessage;
session.Device.LastDiagnosticSnapshot = session.Client.CaptureDiagnosticSnapshot(
$"{phase} MMS association attempt #{attempt} failed after {elapsed.TotalMilliseconds:0} ms");

var failure = string.IsNullOrWhiteSpace(session.Client.LastErrorMessage)
? $"native state={session.Client.NativeState}"
: session.Client.LastErrorMessage;
// Fail closed on unclassified errors and ACSE/MMS refusal. Do not
// repeat a confirmed protocol rejection just to satisfy a retry count.
var retry = attempt < SmartReconnectPolicy.InitialDiscoveryAssociationMaxAttempts &&
SmartReconnectPolicy.CanRetryInitialDiscoveryAssociation(failureKind);
Log("WARN", session.Device.Name,
$"Fast saved-model connection attempt #{attempt} did not establish MMS ({failure}). Retrying once after {SmartReconnectPolicy.InitialAssociationRetryDelay.TotalMilliseconds:0} ms.");
$"{phase} MMS association attempt #{attempt} failed after {elapsed.TotalMilliseconds:0} ms " +
$"(kind={failureKind}, retry={retry}). {failureDetail}");

var staleClient = session.Client;
if (!retry)
return;

// A new client gives the next TCP/COTP/ACSE/MMS attempt a new
// ownership scope. Never recycle a failed protocol session.
var oldClient = session.Client;
session.Client = new NativeIec61850Client();
await DisposeClientForReconnectAsync(
staleClient,
session.Device.Name,
SmartReconnectPolicy.ClientCleanupBudget,
oldClient, session.Device.Name,
SmartReconnectPolicy.ClientCleanupBudget, cancellationToken).ConfigureAwait(false);
await Task.Delay(
SmartReconnectPolicy.InitialAssociationRetryDelay,
cancellationToken).ConfigureAwait(false);
await Task.Delay(SmartReconnectPolicy.InitialAssociationRetryDelay, cancellationToken).ConfigureAwait(false);
}
}

Expand Down
58 changes: 58 additions & 0 deletions Services/Iec61850TwoBitStatusPresentation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
namespace ArIED61850Tester.Services;

/// <summary>
/// Single, side-effect-free operator presentation boundary for two-bit MMS
/// status values. The raw association ledger, quality flags, event ordering and
/// typed decoder are not changed. DPC positions use their own CDC-aware path.
/// </summary>
internal static class Iec61850TwoBitStatusPresentation
{
internal static bool TryFormat(
string wireValue,
string? dataType,
string? category,
string? reference,
out string display)
{
display = string.Empty;

// Metadata authority is required. A Measurement that happens to be two
// bits must not inherit a process-state meaning from its byte shape.
if (!string.Equals(category?.Trim(), "Status", StringComparison.OrdinalIgnoreCase))
return false;

var path = reference.AsSpan().Trim();
if (!path.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase) &&
!path.EndsWith("$stVal", StringComparison.OrdinalIgnoreCase))
return false;

// Fail closed when declared MMS/IEC type contradicts a two-bit status.
// Unknown is a legacy incomplete-model state, not a vendor exception.
if (!IsBitStringOrIncompleteType(dataType))
return false;

if (!Iec61850ValueFormatter.TryNormalizeTwoBitWireStatus(wireValue, out var code))
return false;

display = code switch
{
0 => "Bits [00]",
1 => "Bits [01]",
2 => "Bits [10]",
3 => "Bits [11]",
_ => string.Empty
};
return display.Length != 0;
}

private static bool IsBitStringOrIncompleteType(string? dataType)
{
var type = dataType?.Trim();
return string.IsNullOrEmpty(type) ||
type.Equals("Unknown", StringComparison.OrdinalIgnoreCase) ||
type.Equals("BitString", StringComparison.OrdinalIgnoreCase) ||
type.Equals("Bit String", StringComparison.OrdinalIgnoreCase) ||
type.Equals("Bits", StringComparison.OrdinalIgnoreCase) ||
type.Equals("MmsBitString", StringComparison.OrdinalIgnoreCase);
}
}
8 changes: 8 additions & 0 deletions Services/Iec61850ValueFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,14 @@ public static string FormatReportProcessValue(
return boolean ? "True [1]" : "False [0]";
}

// The protocol decoder owns raw MMS truth. Only a typed operator
// status projection may render a two-bit status; never mutate MMS
// measurements, quality evidence or an unknown semantic field.
if (normalizedValue is string renderedBits &&
Iec61850TwoBitStatusPresentation.TryFormat(
renderedBits, dataType, category, reference, out var displayBits))
return displayBits;

return FormatNormalizedValue(normalizedValue, dataType, unit);
}

Expand Down
8 changes: 8 additions & 0 deletions Services/SmartReconnectPolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ public static class SmartReconnectPolicy
public static TimeSpan ClientCleanupBudget => TimeSpan.FromMilliseconds(500);
public static TimeSpan ConnectBudget => TimeSpan.FromSeconds(2);
public static TimeSpan InitialAssociationRetryDelay => TimeSpan.FromMilliseconds(500);

// First full discovery had no retry, while a cached-model connection already
// had a bounded transient retry. Never replay discovery or any RCB operation:
// this policy only permits one extra attempt to establish TCP/ACSE/MMS.
public const int InitialDiscoveryAssociationMaxAttempts = 2;

public static bool CanRetryInitialDiscoveryAssociation(string? failureKind)
=> failureKind is "TCP_TIMEOUT" or "TCP_TRANSPORT_CLOSED" or "TCP_CONNECTION_REFUSED" or "NATIVE_ASSOCIATION_CANCELLED";
public static TimeSpan ReportRearmDelay => TimeSpan.FromSeconds(1);
public static TimeSpan ReportRearmDeadline => TimeSpan.FromSeconds(3);
public static TimeSpan RecoveryWarmupDuration => TimeSpan.FromSeconds(3);
Expand Down
19 changes: 19 additions & 0 deletions docs/audits/P8_SIEBCU_FIRST_CONNECT_BITS_REPORT_20261105.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# P8 SIEBCU first-connect / BIT STRING / false reporting warning audit (2026-11-05)

## Operator evidence (P8 trial, SHA 37165a0c / engine ae1ce23d)

Diagnostics generated at 10:53:49 WIB (2026-11-05) for SIEBCU 192.16.1.192:102. Log starts association at 10:53:25.055 and again at 10:53:35.128, with MMS initiated at 10:53:35.182. This establishes two attempts, **not the cause of the first failure**; the retained log does not include a first-attempt exception/rejection. The accepted route is SAS-Port 3 with 192.16.1.92/24, same subnet. The successful smart discovery took ~1756 ms for 211 requests. The trial received 14 Report frames across seven exactly routed RCBs, with seven visible selected values.

At 10:53:42.741, an observer still logged "no InformationReport traffic" despite decoder-based per-RCB evidence showing 7/7 streams with frames at 10:53:41. This is an application diagnostic contradiction, not a proof of wire SOE loss.

The UI displayed `USER2.LOCREM.stVal` as `bits(80, unused=6)`. This raw MMS renderer means one byte 0x80 with six unused padding bits, therefore two significant bits `10`. It does **not** establish a vendor-specific Local/Remote label nor general DPC/Close semantics.

## Source-supported defects and changes

1. Full discovery started its MMS association only once; the cached reconnect route already had a bounded retry. Initial discovery now retries at most once **only** after TCP timeout, connection refused, transport closure, or internal native association cancellation with the parent request still active. It uses a new native client, observes a 500 ms backoff, preserves failure-kind and elapsed-attempt logs, and performs discovery only after the successful association. Explicit cancellation, ACSE rejection, unknown failure and unreachable-network errors do not provoke hidden retries. No reporting/RCB write or control is triggered by retry.
2. The report value formatter already understood valid `bits(HH, unused=6)` but used the decoding only for DPC/position. Exact two-bit non-position values now show `Bits [10]`, without a guessed semantic label; DPC positions retain `Close [10]`. Malformed padding and non-two-bit representations remain unmodified. The optional `BIT` badge is based only on an explicit BitString data-type declaration.
3. The three-second static-report observer used `trafficObserved && HasReportStream` to decide whether to log "no traffic", even when exact per-RCB continuity evidence contained real frames. Now it examines exact routed frame evidence for each RCB in the current association before deciding the verdict; zero-frame peers remain independently unverified. It performs no MMS I/O.

## Safety and acceptance

Changes are scoped to the existing P8 trial branch consumer; ARSAS main, integrated RC, stable v1.6.42, pinned engine baseline, and ARIEC staging/P9 remain unchanged. This change does not prove the first attempt's physical cause, instant first-attempt success, uninterrupted SOE, or SG7 77/77, 4/4 recovery. Validate exact-head CI, then create a separately identifiable Windows nonrelease trial before field retest. Retain raw MMS typed evidence alongside operator display.
Loading
Loading