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: 1 addition & 1 deletion .github/workflows/dotnet-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ on:
pull_request:
# The live ARSAS product pins PR #169 until targeted engine changes pass CI.
# Stacked engine PRs must run the exact same Windows build/test gate.
branches: [ main, feat/495-dataset-type-closure, fix/p10-9l-readonly-ctlmodel-probe ]
branches: [ main, feat/495-dataset-type-closure, fix/p10-9l-readonly-ctlmodel-probe, fix/p0-b-target-scoped-rcb-pager-20261019 ]
workflow_dispatch:

permissions:
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Control/report isolation trial — 2026-10-20

- Reject non-RCB InformationReport shapes before persistent single-monitor fallback; control termination and LastApplError remain available to independent control subscribers.
- Include monotonic service duration and post-response wait in each control wire-step detail. UTC marks local service start, not wire send. This separates SBOw/Operate service latency from asynchronous completion wait without changing timeout, checks, or command sequencing.
- Synthetic regression coverage added; physical latency improvement is not yet established.

All notable public changes to ARIEC61850 are recorded here. The project is still evolving toward stable semantic releases; entries are grouped by engineering milestone until a formal versioning policy is adopted.

## Unreleased — public wording, provenance and release assurance
Expand Down
44 changes: 38 additions & 6 deletions src/AR.Iec61850/Control/Iec61850ControlObjectSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ private sealed class AssociationControlState
private CancellationTokenSource? _selectionLeaseCts;
private bool _ownsLock;
private bool _disposed;
private DateTimeOffset _serviceStartedUtc;
private TimeSpan _serviceElapsed;
private long _serviceCompletedTimestamp;


internal Iec61850ControlObjectSession(
IIec61850ControlTransport transport,
Expand Down Expand Up @@ -115,7 +119,7 @@ private async Task<Iec61850ControlActionResult> SelectCoreAsync(
try
{
var context = CreateContext(request);
var read = await _transport.ReadAsync(Descriptor.References.Sbo, cancellationToken).ConfigureAwait(false);
var read = await MeasureServiceAsync(() => _transport.ReadAsync(Descriptor.References.Sbo, cancellationToken)).ConfigureAwait(false);
if (!read.IsSuccess || !IsPositiveSboSelection(read.Value, Descriptor.References))
{
ReleaseObject();
Expand Down Expand Up @@ -172,7 +176,7 @@ private async Task<Iec61850ControlActionResult> SelectWithValueCoreAsync(
context,
Descriptor.SelectWithValueSpecification,
_options.RequireExactNamedControlFields);
var write = await _transport.WriteControlAsync(Descriptor.References.SboWithValue, value, cancellationToken).ConfigureAwait(false);
var write = await MeasureServiceAsync(() => _transport.WriteControlAsync(Descriptor.References.SboWithValue, value, cancellationToken)).ConfigureAwait(false);
if (!write.IsSuccess)
{
var appError = await WaitForApplicationErrorAsync(_options.ApplicationErrorGracePeriod, cancellationToken, reports).ConfigureAwait(false);
Expand Down Expand Up @@ -289,7 +293,7 @@ private async Task<Iec61850ControlActionResult> OperateCoreAsync(
context,
Descriptor.OperSpecification,
_options.RequireExactNamedControlFields);
var write = await _transport.WriteControlAsync(Descriptor.References.Oper, operValue, linked.Token).ConfigureAwait(false);
var write = await MeasureServiceAsync(() => _transport.WriteControlAsync(Descriptor.References.Oper, operValue, linked.Token)).ConfigureAwait(false);
if (!write.IsSuccess)
{
var applicationError = await WaitForApplicationErrorAsync(_options.ApplicationErrorGracePeriod, cancellationToken, reports).ConfigureAwait(false);
Expand Down Expand Up @@ -432,7 +436,7 @@ private async Task<Iec61850ControlActionResult> CancelCoreAsync(CancellationToke
return Unsupported(Iec61850ControlAction.Cancel, "IED did not expose a decodable Cancel type specification.");

var value = Iec61850ControlStructureBuilder.BuildCancel(context, Descriptor.CancelSpecification, _options.RequireExactNamedControlFields);
var write = await _transport.WriteControlAsync(Descriptor.References.Cancel, value, cancellationToken).ConfigureAwait(false);
var write = await MeasureServiceAsync(() => _transport.WriteControlAsync(Descriptor.References.Cancel, value, cancellationToken)).ConfigureAwait(false);
return write.IsSuccess
? Accepted(Iec61850ControlAction.Cancel, context, stopwatch.Elapsed, "Cancel accepted; local SBO ownership released.")
: FromWriteFailure(Iec61850ControlAction.Cancel, write, null, context, stopwatch.Elapsed);
Expand Down Expand Up @@ -691,7 +695,7 @@ private async Task BestEffortCancelAsync(CancellationToken cancellationToken)
context,
Descriptor.CancelSpecification,
_options.RequireExactNamedControlFields);
await _transport.WriteControlAsync(Descriptor.References.Cancel, value, cancellationToken).ConfigureAwait(false);
await MeasureServiceAsync(() => _transport.WriteControlAsync(Descriptor.References.Cancel, value, cancellationToken)).ConfigureAwait(false);
}
}
catch
Expand Down Expand Up @@ -782,6 +786,20 @@ private Iec61850ControlActionResult FromApplicationRejection(
BuildWireStep(action, requestAccepted: true, clientMessage))
};

// Serialized by _operationGate. These are local service boundaries, not
// packet capture timestamps; elapsed measurement uses the monotonic clock.
private async Task<T> MeasureServiceAsync<T>(Func<Task<T>> service)
{
_serviceStartedUtc = DateTimeOffset.UtcNow;
var started = Stopwatch.GetTimestamp();
try { return await service().ConfigureAwait(false); }
finally
{
_serviceElapsed = Stopwatch.GetElapsedTime(started);
_serviceCompletedTimestamp = Stopwatch.GetTimestamp();
}
}

private Iec61850ControlWireStep BuildWireStep(
Iec61850ControlAction action,
bool requestAccepted,
Expand All @@ -796,14 +814,28 @@ private Iec61850ControlWireStep BuildWireStep(
_ => Descriptor.References.Oper
};

// Distinguish protocol service response time from the selection
// negative-indication observation gate. The latter dominates the
// field sample (~400 ms); it must not be misreported as slow MMS I/O.
// This is telemetry only: do not reduce the grace period or bypass
// LastApplError and enhanced-security CommandTermination.
var postResponseStage = action switch
{
Iec61850ControlAction.SelectWithValue => "sbo-application-error-observation",
Iec61850ControlAction.Operate => "operate-termination-observation",
_ => "post-service-completion"
};
var graceBudget = action == Iec61850ControlAction.SelectWithValue
? _options.ApplicationErrorGracePeriod.TotalMilliseconds : 0d;
return new Iec61850ControlWireStep
{
Action = action,
Reference = $"{reference.Domain}/{reference.Item}",
RequestAccepted = requestAccepted,
RequestHex = _transport.LastRequestHex,
ResponseHex = _transport.LastResponseHex,
Detail = detail
Detail = FormattableString.Invariant(
$"{detail} serviceStartedUtc={_serviceStartedUtc:O}; serviceMs={_serviceElapsed.TotalMilliseconds:0.###}; postResponseWaitMs={Stopwatch.GetElapsedTime(_serviceCompletedTimestamp).TotalMilliseconds:0.###}; postResponseStage={postResponseStage}; configuredApplicationErrorGraceMs={graceBudget:0.###}; timingScope=local-service-not-wire")
};
}

Expand Down
17 changes: 11 additions & 6 deletions src/AR.Iec61850/Mms/MmsDataCodec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,16 @@ public static MmsDataValue Decode(BerTlv tlv)
{
1 => MmsDataValue.Array(DecodeAllData(tlv.Value)),
2 => MmsDataValue.Structure(DecodeAllData(tlv.Value)),
3 => MmsDataValue.Boolean(BerReader.ReadBoolean(tlv) ?? false),
3 => tlv.Value.Length == 1 && BerReader.ReadBoolean(tlv) is { } booleanValue
? MmsDataValue.Boolean(booleanValue) : MmsDataValue.Unknown(tlv.TagNumber, tlv.Value.Span),
4 => DecodeBitString(tlv.Value.Span),
5 => MmsDataValue.Integer(BerReader.ReadSignedInteger(tlv) ?? 0),
6 => MmsDataValue.Unsigned(BerReader.ReadUnsignedInteger(tlv) ?? 0),
7 => MmsDataValue.FloatingPoint(DecodeFloatingPoint(tlv.Value.Span)),
5 => tlv.Value.Length is > 0 and <= 8 && BerReader.ReadSignedInteger(tlv) is { } signed
? MmsDataValue.Integer(signed) : MmsDataValue.Unknown(tlv.TagNumber, tlv.Value.Span),
6 => tlv.Value.Length is > 0 and <= 8 && BerReader.ReadUnsignedInteger(tlv) is { } unsigned
? MmsDataValue.Unsigned(unsigned) : MmsDataValue.Unknown(tlv.TagNumber, tlv.Value.Span),
7 => tlv.Value.Length is 4 or 5
? MmsDataValue.FloatingPoint(DecodeFloatingPoint(tlv.Value.Span))
: MmsDataValue.Unknown(tlv.TagNumber, tlv.Value.Span),
9 => MmsDataValue.OctetString(tlv.Value.Span),
10 => MmsDataValue.VisibleString(BerReader.ReadAsciiString(tlv)),
12 => MmsDataValue.BinaryTime(tlv.Value.Span),
Expand Down Expand Up @@ -126,8 +131,8 @@ private static byte TagFor(MmsDataKind kind, int? unknownTagNumber)

private static MmsDataValue DecodeBitString(ReadOnlySpan<byte> bytes)
{
if (bytes.IsEmpty)
return MmsDataValue.BitString(0, ReadOnlySpan<byte>.Empty);
if (bytes.IsEmpty || bytes[0] > 7 || (bytes.Length == 1 && bytes[0] != 0))
return MmsDataValue.Unknown(4, bytes);

return MmsDataValue.BitString(bytes[0], bytes[1..]);
}
Expand Down
11 changes: 11 additions & 0 deletions src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,12 @@ private void RoutePersistentInformationReport(byte[] payload)
return;
}

// Control termination / LastApplError use the same MMS service but are
// not RCB reports. Never feed them to the single-monitor fallback.
// Their independent control subscribers already received the envelope.
if (!HasRcbReportHeader(decoded))
return;

var header = MmsReportFrameMapper.DecodeHeader(decoded);
var active = SnapshotPersistentReportMonitors();
var target = SelectPersistentReportMonitor(active, header, out var evidence);
Expand All @@ -655,6 +661,11 @@ private void RoutePersistentInformationReport(byte[] payload)
$"Routed InformationReport to {target.ReportControl.Reference} by {evidence}. RptID={RoutingTextOrDash(header.ReportId)}, DatSet={RoutingTextOrDash(header.DataSetReference)}.";
}

internal static bool HasRcbReportHeader(MmsInformationReport report)
=> report.IsSuccess && report.Items.Count >= 2 &&
report.Items[0].Value?.Kind is MmsDataKind.VisibleString or MmsDataKind.MmsString &&
report.Items[1].Value?.Kind == MmsDataKind.BitString;

internal static MmsPersistentReportMonitorSession? SelectPersistentReportMonitor(
IReadOnlyList<MmsPersistentReportMonitorSession> active,
MmsReportHeader header,
Expand Down
16 changes: 11 additions & 5 deletions src/AR.Iec61850/Mms/MmsRead.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public sealed class MmsReadResult
public bool IsSuccess { get; init; }
public MmsDataValue? Value { get; init; }
public int? FailureCode { get; init; }
public MmsReadEvidenceFailureKind FailureKind { get; init; }
public string Message { get; init; } = string.Empty;
public string ResponseHexPreview { get; init; } = string.Empty;
}
Expand Down Expand Up @@ -93,10 +94,10 @@ public static MmsReadResult DecodeSingleVariable(ReadOnlyMemory<byte> presentati
return Fail("Empty MMS response payload.", hex);

if (mms[0] == 0xA2)
return Fail($"MMS Confirmed-Error PDU received: {HexDump.ToCompactString(mms)}", hex);
return Fail($"MMS Confirmed-Error PDU received: {HexDump.ToCompactString(mms)}", hex, kind: MmsReadEvidenceFailureKind.ProtocolRejected);

if (mms[0] == 0xA3 || mms[0] == 0xA4)
return Fail($"MMS Reject/Abort PDU received: {HexDump.ToCompactString(mms)}", hex);
return Fail($"MMS Reject/Abort PDU received: {HexDump.ToCompactString(mms)}", hex, kind: MmsReadEvidenceFailureKind.ProtocolRejected);

if (!TryValidateConfirmedResponse(mms, expectedInvokeId, out var message))
return Fail(message, hex);
Expand All @@ -113,7 +114,9 @@ public static MmsReadResult DecodeSingleVariable(ReadOnlyMemory<byte> presentati
}

if (!string.IsNullOrWhiteSpace(accessMessage))
return Fail(accessMessage, hex, failureCode);
return Fail(accessMessage, hex, failureCode, failureCode.HasValue
? MmsReadEvidenceClassifier.FromAccessCode(failureCode)
: MmsReadEvidenceFailureKind.InvalidResponse);

// Legacy fallback for tolerant decoding of non-standard/short responses.
var values = new List<BerTlv>();
Expand All @@ -123,7 +126,8 @@ public static MmsReadResult DecodeSingleVariable(ReadOnlyMemory<byte> presentati
if (failure.EncodedTag == 0x80)
{
var code = BerReader.ReadUnsignedInteger(failure);
return Fail(FormatDataAccessFailure(code), hex, code.HasValue ? (int)code.Value : null);
return Fail(FormatDataAccessFailure(code), hex, code.HasValue ? (int)code.Value : null,
MmsReadEvidenceClassifier.FromAccessCode(code.HasValue ? (int)code.Value : null));
}

foreach (var tlv in values.AsEnumerable().Reverse())
Expand Down Expand Up @@ -365,12 +369,14 @@ private static string DataAccessErrorName(ulong code)
_ => "unknown"
};

private static MmsReadResult Fail(string message, string hex, int? failureCode = null)
private static MmsReadResult Fail(string message, string hex, int? failureCode = null,
MmsReadEvidenceFailureKind kind = MmsReadEvidenceFailureKind.InvalidResponse)
{
return new MmsReadResult
{
IsSuccess = false,
FailureCode = failureCode,
FailureKind = kind,
Message = message,
ResponseHexPreview = hex
};
Expand Down
19 changes: 18 additions & 1 deletion src/AR.Iec61850/Mms/MmsReadBatch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public sealed class MmsReadAccessResult
public bool IsSuccess { get; init; }
public MmsDataValue? Value { get; init; }
public int? FailureCode { get; init; }
public MmsReadEvidenceFailureKind FailureKind { get; init; }
public string Message { get; init; } = string.Empty;
}

Expand Down Expand Up @@ -124,6 +125,14 @@ public static MmsReadBatchResult DecodeResponse(
return FailAll(references, "MMS Read response has no listOfAccessResult.", hex);

var accessResults = BerReader.ReadChildren(accessList.Value);
// A Read response with missing/extra access results cannot be
// safely joined to requested FC tuples by ordinal. Retain the
// mismatch as failure evidence instead of accepting a prefix.
if (accessResults.Count != references.Count)
return FailAll(references,
$"MMS Read AccessResult count mismatch: requested={references.Count}, received={accessResults.Count}.",
hex, Math.Max(0, accessResults.Count - references.Count));

var results = new List<MmsReadAccessResult>(references.Count);
for (var index = 0; index < references.Count; index++)
{
Expand All @@ -133,6 +142,7 @@ public static MmsReadBatchResult DecodeResponse(
{
Reference = references[index],
IsSuccess = false,
FailureKind = MmsReadEvidenceFailureKind.InvalidResponse,
Message = $"MMS Read response ended before access result {index}."
});
continue;
Expand Down Expand Up @@ -166,6 +176,7 @@ private static MmsReadAccessResult DecodeAccessResult(MmsObjectReference referen
Reference = reference,
IsSuccess = false,
FailureCode = code.HasValue ? (int)code.Value : null,
FailureKind = MmsReadEvidenceClassifier.FromAccessCode(code.HasValue ? (int)code.Value : null),
Message = code.HasValue
? $"MMS Read AccessResult.failure code {code.Value}."
: "MMS Read AccessResult.failure code is undecodable."
Expand All @@ -180,6 +191,7 @@ private static MmsReadAccessResult DecodeAccessResult(MmsObjectReference referen
Reference = reference,
IsSuccess = value.Kind != MmsDataKind.Unknown,
Value = value.Kind == MmsDataKind.Unknown ? null : value,
FailureKind = value.Kind == MmsDataKind.Unknown ? MmsReadEvidenceFailureKind.InvalidResponse : MmsReadEvidenceFailureKind.None,
Message = value.Kind == MmsDataKind.Unknown ? "MMS Data value is unknown." : "MMS Data value decoded."
};
}
Expand All @@ -195,6 +207,7 @@ private static MmsReadAccessResult DecodeAccessResult(MmsObjectReference referen
Reference = reference,
IsSuccess = value.Kind != MmsDataKind.Unknown,
Value = value.Kind == MmsDataKind.Unknown ? null : value,
FailureKind = value.Kind == MmsDataKind.Unknown ? MmsReadEvidenceFailureKind.InvalidResponse : MmsReadEvidenceFailureKind.None,
Message = value.Kind == MmsDataKind.Unknown ? "Wrapped MMS Data value is unknown." : "Wrapped MMS Data value decoded."
};
}
Expand All @@ -204,6 +217,7 @@ private static MmsReadAccessResult DecodeAccessResult(MmsObjectReference referen
{
Reference = reference,
IsSuccess = false,
FailureKind = MmsReadEvidenceFailureKind.InvalidResponse,
Message = $"Unsupported MMS AccessResult tag 0x{accessResult.EncodedTag:X2}."
};
}
Expand All @@ -224,13 +238,16 @@ private static bool IsMmsData(BerTlv node)
private static MmsReadBatchResult FailAll(
IReadOnlyList<MmsObjectReference> references,
string message,
string hex)
string hex,
int extraAccessResultCount = 0)
=> new()
{
ExtraAccessResultCount = extraAccessResultCount,
Results = references.Select(reference => new MmsReadAccessResult
{
Reference = reference,
IsSuccess = false,
FailureKind = MmsReadEvidenceFailureKind.InvalidResponse,
Message = message
}).ToArray(),
Message = message,
Expand Down
32 changes: 32 additions & 0 deletions src/AR.Iec61850/Mms/MmsReadEvidenceClassifier.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace AR.Iec61850.Mms;

/// <summary>
/// Evidence classification is orthogonal to MMS Data type: a failed access
/// never becomes a scalar, including when an expected SCL type exists.
/// </summary>
public enum MmsReadEvidenceFailureKind
{
None,
AccessDenied,
NotSupported,
TypeMismatch,
ObjectMissing,
TemporarilyUnavailable,
RemoteAccessFailure,
InvalidResponse,
ProtocolRejected
}

public static class MmsReadEvidenceClassifier
{
public static MmsReadEvidenceFailureKind FromAccessCode(int? code)
=> code switch
{
2 => MmsReadEvidenceFailureKind.TemporarilyUnavailable,
3 => MmsReadEvidenceFailureKind.AccessDenied,
4 or 5 or 10 => MmsReadEvidenceFailureKind.ObjectMissing,
6 or 9 => MmsReadEvidenceFailureKind.NotSupported,
7 or 8 => MmsReadEvidenceFailureKind.TypeMismatch,
_ => MmsReadEvidenceFailureKind.RemoteAccessFailure
};
}
Loading