diff --git a/src/AR.Iec61850.Simulation/MmsServer/IedSimulatorMmsServer.cs b/src/AR.Iec61850.Simulation/MmsServer/IedSimulatorMmsServer.cs
index a03c3de..cd11cf3 100644
--- a/src/AR.Iec61850.Simulation/MmsServer/IedSimulatorMmsServer.cs
+++ b/src/AR.Iec61850.Simulation/MmsServer/IedSimulatorMmsServer.cs
@@ -22,6 +22,15 @@ public sealed class IedSimulatorMmsServerOptions
/// Maximum number of recent activity records kept in memory for monitoring.
public int ActivityHistoryLimit { get; init; } = 500;
+
+ ///
+ /// Optional per-association application runtime. The persistent MMS server always keeps its
+ /// built-in RCB/reporting runtime; when this factory returns another runtime both are composed.
+ /// Applications can therefore add writable process controls/settings while the protocol stack,
+ /// association lifecycle, reporting and read-only default guard remain owned by ARIEC61850.
+ /// The remote endpoint is supplied for audit/policy decisions.
+ ///
+ public Func? AssociationRuntimeFactory { get; init; }
}
public enum IedSimulatorServerActivityKind
@@ -59,19 +68,17 @@ public sealed record IedSimulatorServerActivity
}
///
-/// A runnable, persistent, read-only IEC 61850 MMS server for the IED simulator. It binds a TCP
+/// A runnable, persistent IEC 61850 MMS server for the IED simulator. It binds a TCP
/// listener, accepts external clients (for example IED Discovery or another MMS browser), runs the
/// TPKT/COTP/ACSE association, and answers native MMS BER confirmed requests from a live snapshot of
-/// the simulator model. Writes and controls are rejected by the underlying read-only session guard.
+/// the simulator model. The default data model remains read-only; an application may opt in to
+/// additional per-association writable process semantics through .
///
/// This is the "Open SCL → Run" capability: combined with a
/// caller can load any SCL model and serve it. All protocol encode/decode is delegated to the existing
/// tested codecs (TpktFrameCodec, CotpFrameCodec, AcseMmsAssociateResponse) and
/// the MmsConfirmedRequestBerDispatcher; this class only owns the socket lifecycle and the
/// per-association loop.
-///
-/// Scope: read-only confirmed services (GetNameList, Read, GetNamedVariableListAttributes, Write
-/// rejection). Reports, GOOSE/SV publishing, and control remain future milestones.
///
public sealed class IedSimulatorMmsServer : IAsyncDisposable
{
@@ -247,6 +254,7 @@ private async Task HandleConnectionAsync(int connectionId, TcpClient client, Can
// Serializes MMS confirmed responses and unsolicited InformationReports onto one stream.
using var writeLock = new SemaphoreSlim(1, 1);
MmsAssociationReportingRuntime? reportingRuntime = null;
+ IMmsAssociationRuntime? associationRuntime = null;
try
{
await using var stream = client.GetStream();
@@ -280,6 +288,11 @@ private async Task HandleConnectionAsync(int connectionId, TcpClient client, Can
Message = message
}));
+ var applicationRuntime = _options.AssociationRuntimeFactory?.Invoke(remote);
+ associationRuntime = applicationRuntime is null
+ ? reportingRuntime
+ : new MmsCompositeAssociationRuntime(reportingRuntime, applicationRuntime);
+
while (!cancellationToken.IsCancellationRequested)
{
var requestPayload = await ReadCotpDataPayloadAsync(stream, cancellationToken).ConfigureAwait(false);
@@ -293,7 +306,7 @@ private async Task HandleConnectionAsync(int connectionId, TcpClient client, Can
activeResponseCotpSegmentCount = 0;
var session = _sessionFactory();
- var dispatch = MmsConfirmedRequestBerDispatcher.Dispatch(requestPayload, session, association.PresentationContextId, reportingRuntime);
+ var dispatch = MmsConfirmedRequestBerDispatcher.Dispatch(requestPayload, session, association.PresentationContextId, associationRuntime);
if (!dispatch.IsRequestDecoded)
{
var hasErrorResponse = dispatch.ResponsePresentationPayload.Length > 0;
@@ -399,7 +412,11 @@ private async Task HandleConnectionAsync(int connectionId, TcpClient client, Can
}
finally
{
- reportingRuntime?.Dispose();
+ if (associationRuntime is IDisposable disposable)
+ disposable.Dispose();
+ else
+ reportingRuntime?.Dispose();
+
_clients.TryRemove(connectionId, out _);
try { client.Close(); }
catch (Exception ex) when (ex is SocketException or ObjectDisposedException) { }
diff --git a/src/AR.Iec61850.Simulation/MmsServer/MmsCompositeAssociationRuntime.cs b/src/AR.Iec61850.Simulation/MmsServer/MmsCompositeAssociationRuntime.cs
new file mode 100644
index 0000000..6784b33
--- /dev/null
+++ b/src/AR.Iec61850.Simulation/MmsServer/MmsCompositeAssociationRuntime.cs
@@ -0,0 +1,60 @@
+using AR.Iec61850.Mms;
+
+namespace AR.Iec61850.Simulation;
+
+///
+/// Composes multiple per-association MMS runtimes. The first runtime that claims
+/// a target owns the read/write result. This lets the persistent simulator server
+/// keep the standard report-control-block runtime while an application adds
+/// process controls or writable setting points without duplicating the MMS stack.
+///
+public sealed class MmsCompositeAssociationRuntime : IMmsAssociationRuntime, IDisposable
+{
+ private readonly IMmsAssociationRuntime[] _runtimes;
+ private bool _disposed;
+
+ public MmsCompositeAssociationRuntime(params IMmsAssociationRuntime[] runtimes)
+ {
+ ArgumentNullException.ThrowIfNull(runtimes);
+ _runtimes = runtimes.Where(x => x is not null).ToArray();
+ if (_runtimes.Length == 0)
+ throw new ArgumentException("At least one association runtime is required.", nameof(runtimes));
+ }
+
+ public bool TryReadRcbAttribute(string iecTarget, out MmsDataValue value)
+ {
+ foreach (var runtime in _runtimes)
+ {
+ if (runtime.TryReadRcbAttribute(iecTarget, out value))
+ return true;
+ }
+
+ value = MmsDataValue.Boolean(false);
+ return false;
+ }
+
+ public bool TryWriteRcbAttribute(string iecTarget, MmsDataValue value, out int dataAccessError)
+ {
+ foreach (var runtime in _runtimes)
+ {
+ if (runtime.TryWriteRcbAttribute(iecTarget, value, out dataAccessError))
+ return true;
+ }
+
+ dataAccessError = 0;
+ return false;
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+
+ _disposed = true;
+ foreach (var runtime in _runtimes.Reverse())
+ {
+ if (runtime is IDisposable disposable)
+ disposable.Dispose();
+ }
+ }
+}
diff --git a/src/AR.Iec61850.Simulation/MmsServer/MmsReportingRuntime.cs b/src/AR.Iec61850.Simulation/MmsServer/MmsReportingRuntime.cs
index 87f4a9e..767fcbd 100644
--- a/src/AR.Iec61850.Simulation/MmsServer/MmsReportingRuntime.cs
+++ b/src/AR.Iec61850.Simulation/MmsServer/MmsReportingRuntime.cs
@@ -11,22 +11,10 @@ namespace AR.Iec61850.Simulation;
///
public interface IMmsAssociationRuntime
{
- /// Returns true when resolves to an RCB or RCB attribute owned by this association.
bool TryReadRcbAttribute(string iecTarget, out MmsDataValue value);
-
- ///
- /// Returns true when the target is an RCB attribute (the write was handled here).
- /// On failure carries the ISO 9506 DataAccessError code.
- ///
bool TryWriteRcbAttribute(string iecTarget, MmsDataValue value, out int dataAccessError);
}
-///
-/// The single source of truth for the MMS attribute layout of report control blocks. The read-only
-/// session uses it to build TypeDescriptions and the reporting runtime uses it to build live value
-/// structures, so the type a client discovers always matches the values it reads.
-/// Order and members follow IEC 61850-8-1 (URCB: RptID..GI, BRCB: RptID..TimeOfEntry).
-///
public static class MmsReportControlBlockLayout
{
public static IReadOnlyList<(string Name, string BType)> AttributesFor(bool buffered)
@@ -34,40 +22,21 @@ public static class MmsReportControlBlockLayout
private static readonly (string Name, string BType)[] UrcbAttributes =
[
- ("RptID", "VisString129"),
- ("RptEna", "BOOLEAN"),
- ("Resv", "BOOLEAN"),
- ("DatSet", "VisString129"),
- ("ConfRev", "INT32U"),
- ("OptFlds", "OPTFLDS"),
- ("BufTm", "INT32U"),
- ("SqNum", "INT8U"),
- ("TrgOps", "TRGOPS"),
- ("IntgPd", "INT32U"),
- ("GI", "BOOLEAN")
+ ("RptID", "VisString129"), ("RptEna", "BOOLEAN"), ("Resv", "BOOLEAN"),
+ ("DatSet", "VisString129"), ("ConfRev", "INT32U"), ("OptFlds", "OPTFLDS"),
+ ("BufTm", "INT32U"), ("SqNum", "INT8U"), ("TrgOps", "TRGOPS"),
+ ("IntgPd", "INT32U"), ("GI", "BOOLEAN")
];
private static readonly (string Name, string BType)[] BrcbAttributes =
[
- ("RptID", "VisString129"),
- ("RptEna", "BOOLEAN"),
- ("DatSet", "VisString129"),
- ("ConfRev", "INT32U"),
- ("OptFlds", "OPTFLDS"),
- ("BufTm", "INT32U"),
- ("SqNum", "INT16U"),
- ("TrgOps", "TRGOPS"),
- ("IntgPd", "INT32U"),
- ("GI", "BOOLEAN"),
- ("PurgeBuf", "BOOLEAN"),
- ("EntryID", "ENTRYID"),
- ("TimeOfEntry", "ENTRYTIME"),
- ("ResvTms", "INT16")
+ ("RptID", "VisString129"), ("RptEna", "BOOLEAN"), ("DatSet", "VisString129"),
+ ("ConfRev", "INT32U"), ("OptFlds", "OPTFLDS"), ("BufTm", "INT32U"),
+ ("SqNum", "INT16U"), ("TrgOps", "TRGOPS"), ("IntgPd", "INT32U"),
+ ("GI", "BOOLEAN"), ("PurgeBuf", "BOOLEAN"), ("EntryID", "ENTRYID"),
+ ("TimeOfEntry", "ENTRYTIME"), ("ResvTms", "INT16")
];
- // OptFlds packed-list bits (bit 0 = MSB of the first byte, bit 0 itself is reserved):
- // 1 sequence-number, 2 report-time-stamp, 3 reason-for-inclusion, 4 data-set-name,
- // 5 data-reference, 6 buffer-overflow, 7 entryID, 8 conf-revision, 9 segmentation.
public static byte[] ParseOptionalFields(string tokens)
{
byte first = 0;
@@ -87,12 +56,9 @@ public static byte[] ParseOptionalFields(string tokens)
case "SEGMENTATION": second |= 0x40; break;
}
}
-
return [first, second];
}
- // TrgOps packed-list bits (bit 0 reserved): 1 data-change, 2 quality-change,
- // 3 data-update, 4 integrity, 5 general-interrogation.
public static byte ParseTriggerOptions(string tokens)
{
byte bits = 0;
@@ -107,7 +73,6 @@ public static byte ParseTriggerOptions(string tokens)
case "GI" or "GENERAL-INTERROGATION": bits |= 0x04; break;
}
}
-
return bits;
}
@@ -119,7 +84,9 @@ public static byte ParseTriggerOptions(string tokens)
public static bool OptionalFieldBufferOverflow(IReadOnlyList optFlds) => (First(optFlds) & 0x02) != 0;
public static bool OptionalFieldEntryId(IReadOnlyList optFlds) => (First(optFlds) & 0x01) != 0;
public static bool OptionalFieldConfRev(IReadOnlyList optFlds) => (Second(optFlds) & 0x80) != 0;
-
+ public static bool TriggerDataChange(byte trgOps) => (trgOps & 0x40) != 0;
+ public static bool TriggerQualityChange(byte trgOps) => (trgOps & 0x20) != 0;
+ public static bool TriggerDataUpdate(byte trgOps) => (trgOps & 0x10) != 0;
public static bool TriggerIntegrity(byte trgOps) => (trgOps & 0x08) != 0;
public static bool TriggerGeneralInterrogation(byte trgOps) => (trgOps & 0x04) != 0;
@@ -127,33 +94,21 @@ public static byte[] ToBinaryTime6(DateTimeOffset timestamp)
{
var utc = timestamp.ToUniversalTime();
var days = (int)(utc.UtcDateTime.Date - new DateTime(1984, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalDays;
- if (days < 0)
- days = 0;
+ if (days < 0) days = 0;
var milliseconds = (uint)utc.TimeOfDay.TotalMilliseconds;
- return
- [
- (byte)(milliseconds >> 24), (byte)(milliseconds >> 16), (byte)(milliseconds >> 8), (byte)milliseconds,
- (byte)(days >> 8), (byte)days
- ];
+ return [(byte)(milliseconds >> 24), (byte)(milliseconds >> 16), (byte)(milliseconds >> 8), (byte)milliseconds, (byte)(days >> 8), (byte)days];
}
private static byte First(IReadOnlyList bytes) => bytes.Count > 0 ? bytes[0] : (byte)0;
private static byte Second(IReadOnlyList bytes) => bytes.Count > 1 ? bytes[1] : (byte)0;
-
private static IEnumerable SplitTokens(string tokens)
- => (tokens ?? string.Empty)
- .Split([',', ';', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
- .Select(x => x.ToUpperInvariant());
+ => (tokens ?? string.Empty).Split([',', ';', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(x => x.ToUpperInvariant());
}
-/// Live, per-association state of one report control block.
public sealed class MmsRcbRuntimeState
{
public required MmsReadOnlyReportControlBlock Definition { get; init; }
-
- /// MMS reference of the control block, e.g. SIE7SL87CTRL/LLN0$RP$A_URCB.
public required string MmsReference { get; init; }
-
public string RptId = string.Empty;
public bool RptEna;
public bool Resv;
@@ -171,11 +126,10 @@ public sealed class MmsRcbRuntimeState
}
///
-/// Per-association reporting engine. It owns the runtime state of every RCB in the served model,
-/// accepts client writes to RCB attributes (RptEna, GI, TrgOps, OptFlds, IntgPd, DatSet, RptID,
-/// BufTm, Resv/ResvTms, PurgeBuf, EntryID), reflects that state on reads, and emits IEC 61850-8-1
-/// unsolicited MMS InformationReport PDUs (general-interrogation and integrity) over the owning
-/// association's socket via the injected send delegate.
+/// Per-association reporting engine. Besides GI and integrity it observes enabled DataSets and
+/// emits unsolicited data-change reports when a member value or quality changes. Observation is
+/// performed inside the server runtime, so connected clients receive report traffic without having
+/// to refresh process values themselves.
///
public sealed class MmsAssociationReportingRuntime : IMmsAssociationRuntime, IDisposable
{
@@ -183,6 +137,7 @@ public sealed class MmsAssociationReportingRuntime : IMmsAssociationRuntime, IDi
private const int DataAccessErrorObjectAccessDenied = 3;
private const int DataAccessErrorTypeInconsistent = 7;
private const int DataAccessErrorObjectNonExistent = 10;
+ private const int DataChangeScanPeriodMs = 50;
private readonly Func _sessionFactory;
private readonly Func _sendPresentationPayload;
@@ -190,29 +145,26 @@ public sealed class MmsAssociationReportingRuntime : IMmsAssociationRuntime, IDi
private readonly Action? _activity;
private readonly Dictionary _states;
private readonly Dictionary _integrityTimers = new(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary _lastDataSetFingerprints = new(StringComparer.OrdinalIgnoreCase);
+ private readonly HashSet _pendingDataChangeReports = new(StringComparer.OrdinalIgnoreCase);
+ private readonly Timer _dataChangeTimer;
private readonly object _gate = new();
private readonly CancellationTokenSource _cts = new();
+ private int _dataChangeScanActive;
private bool _disposed;
- public MmsAssociationReportingRuntime(
- Func sessionFactory,
- Func sendPresentationPayload,
- int presentationContextId = 3,
- Action? activity = null)
+ public MmsAssociationReportingRuntime(Func sessionFactory, Func sendPresentationPayload, int presentationContextId = 3, Action? activity = null)
{
_sessionFactory = sessionFactory ?? throw new ArgumentNullException(nameof(sessionFactory));
_sendPresentationPayload = sendPresentationPayload ?? throw new ArgumentNullException(nameof(sendPresentationPayload));
_presentationContextId = presentationContextId;
_activity = activity;
-
var profile = _sessionFactory().Profile;
_states = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var rcb in profile.ReportControlBlocks)
{
var mmsReference = ToMmsReference(rcb.Reference);
- if (string.IsNullOrWhiteSpace(mmsReference) || _states.ContainsKey(mmsReference))
- continue;
-
+ if (string.IsNullOrWhiteSpace(mmsReference) || _states.ContainsKey(mmsReference)) continue;
_states[mmsReference] = new MmsRcbRuntimeState
{
Definition = rcb,
@@ -226,6 +178,7 @@ public MmsAssociationReportingRuntime(
IntgPd = (uint)Math.Max(0, rcb.IntegrityPeriodMs)
};
}
+ _dataChangeTimer = new Timer(_ => DetectDataChanges(), null, DataChangeScanPeriodMs, DataChangeScanPeriodMs);
}
public IReadOnlyCollection States => _states.Values;
@@ -233,24 +186,16 @@ public MmsAssociationReportingRuntime(
public bool TryReadRcbAttribute(string iecTarget, out MmsDataValue value)
{
value = MmsDataValue.Boolean(false);
- if (!TryResolve(iecTarget, out var state, out var attribute))
- return false;
-
+ if (!TryResolve(iecTarget, out var state, out var attribute)) return false;
lock (_gate)
{
if (attribute.Length == 0)
{
- value = MmsDataValue.Structure(
- MmsReportControlBlockLayout.AttributesFor(state.Definition.Buffered)
- .Select(a => AttributeValue(state, a.Name)));
+ value = MmsDataValue.Structure(MmsReportControlBlockLayout.AttributesFor(state.Definition.Buffered).Select(a => AttributeValue(state, a.Name)));
return true;
}
-
- var known = MmsReportControlBlockLayout.AttributesFor(state.Definition.Buffered)
- .Any(a => string.Equals(a.Name, attribute, StringComparison.OrdinalIgnoreCase));
- if (!known)
- return false;
-
+ var known = MmsReportControlBlockLayout.AttributesFor(state.Definition.Buffered).Any(a => string.Equals(a.Name, attribute, StringComparison.OrdinalIgnoreCase));
+ if (!known) return false;
value = AttributeValue(state, attribute);
return true;
}
@@ -259,210 +204,165 @@ public bool TryReadRcbAttribute(string iecTarget, out MmsDataValue value)
public bool TryWriteRcbAttribute(string iecTarget, MmsDataValue value, out int dataAccessError)
{
dataAccessError = 0;
- if (!TryResolve(iecTarget, out var state, out var attribute) || attribute.Length == 0)
- return false;
-
+ if (!TryResolve(iecTarget, out var state, out var attribute) || attribute.Length == 0) return false;
var sendGeneralInterrogation = false;
+ var seedDataChangeBaseline = false;
lock (_gate)
{
switch (attribute.ToUpperInvariant())
{
case "RPTENA":
- if (!TryBoolean(value, out var enable))
- {
- dataAccessError = DataAccessErrorTypeInconsistent;
- break;
- }
-
+ if (!TryBoolean(value, out var enable)) { dataAccessError = DataAccessErrorTypeInconsistent; break; }
if (enable && !state.RptEna)
{
state.RptEna = true;
state.SqNum = 0;
RestartIntegrityTimerLocked(state);
+ seedDataChangeBaseline = true;
}
else if (!enable && state.RptEna)
{
state.RptEna = false;
StopIntegrityTimerLocked(state);
+ _lastDataSetFingerprints.Remove(state.MmsReference);
+ _pendingDataChangeReports.Remove(state.MmsReference);
}
-
break;
-
case "GI":
- if (!TryBoolean(value, out var gi))
- dataAccessError = DataAccessErrorTypeInconsistent;
- else if (!state.RptEna)
- dataAccessError = DataAccessErrorTemporarilyUnavailable;
- else if (gi)
- sendGeneralInterrogation = true;
+ if (!TryBoolean(value, out var gi)) dataAccessError = DataAccessErrorTypeInconsistent;
+ else if (!state.RptEna) dataAccessError = DataAccessErrorTemporarilyUnavailable;
+ else if (gi) sendGeneralInterrogation = true;
break;
-
- case "RESV":
- if (TryBoolean(value, out var resv))
- state.Resv = resv;
- else
- dataAccessError = DataAccessErrorTypeInconsistent;
- break;
-
- case "RESVTMS":
- if (TrySigned(value, out var resvTms))
- state.ResvTms = (short)Math.Clamp(resvTms, short.MinValue, short.MaxValue);
- else
- dataAccessError = DataAccessErrorTypeInconsistent;
- break;
-
+ case "RESV": if (TryBoolean(value, out var resv)) state.Resv = resv; else dataAccessError = DataAccessErrorTypeInconsistent; break;
+ case "RESVTMS": if (TrySigned(value, out var resvTms)) state.ResvTms = (short)Math.Clamp(resvTms, short.MinValue, short.MaxValue); else dataAccessError = DataAccessErrorTypeInconsistent; break;
case "PURGEBUF":
if (TryBoolean(value, out var purge))
{
state.PurgeBuf = false;
- if (purge)
- {
- state.SqNum = 0;
- System.Array.Clear(state.EntryId, 0, state.EntryId.Length);
- }
- }
- else
- {
- dataAccessError = DataAccessErrorTypeInconsistent;
+ if (purge) { state.SqNum = 0; System.Array.Clear(state.EntryId, 0, state.EntryId.Length); }
}
-
- break;
-
- case "RPTID":
- if (state.RptEna)
- dataAccessError = DataAccessErrorObjectAccessDenied;
- else if (TryString(value, out var rptId))
- state.RptId = rptId;
- else
- dataAccessError = DataAccessErrorTypeInconsistent;
- break;
-
- case "DATSET":
- if (state.RptEna)
- dataAccessError = DataAccessErrorObjectAccessDenied;
- else if (TryString(value, out var datSet))
- state.DatSet = datSet;
- else
- dataAccessError = DataAccessErrorTypeInconsistent;
- break;
-
- case "OPTFLDS":
- if (state.RptEna)
- dataAccessError = DataAccessErrorObjectAccessDenied;
- else if (TryBitString(value, 2, out var optFlds))
- state.OptFlds = optFlds;
- else
- dataAccessError = DataAccessErrorTypeInconsistent;
- break;
-
- case "TRGOPS":
- if (state.RptEna)
- dataAccessError = DataAccessErrorObjectAccessDenied;
- else if (TryBitString(value, 1, out var trgOps))
- state.TrgOps = trgOps.Length > 0 ? trgOps[0] : (byte)0;
- else
- dataAccessError = DataAccessErrorTypeInconsistent;
- break;
-
- case "BUFTM":
- if (state.RptEna)
- dataAccessError = DataAccessErrorObjectAccessDenied;
- else if (TryUnsigned(value, out var bufTm))
- state.BufTm = (uint)Math.Min(bufTm, uint.MaxValue);
- else
- dataAccessError = DataAccessErrorTypeInconsistent;
- break;
-
- case "INTGPD":
- if (state.RptEna)
- dataAccessError = DataAccessErrorObjectAccessDenied;
- else if (TryUnsigned(value, out var intgPd))
- state.IntgPd = (uint)Math.Min(intgPd, uint.MaxValue);
- else
- dataAccessError = DataAccessErrorTypeInconsistent;
- break;
-
- case "ENTRYID":
- if (value.Kind == MmsDataKind.OctetString)
- state.EntryId = value.RawValue.ToArray();
- else
- dataAccessError = DataAccessErrorTypeInconsistent;
- break;
-
- case "SQNUM":
- case "CONFREV":
- case "TIMEOFENTRY":
- case "OWNER":
- dataAccessError = DataAccessErrorObjectAccessDenied;
- break;
-
- default:
- dataAccessError = DataAccessErrorObjectNonExistent;
+ else dataAccessError = DataAccessErrorTypeInconsistent;
break;
+ case "RPTID": if (state.RptEna) dataAccessError = DataAccessErrorObjectAccessDenied; else if (TryString(value, out var rptId)) state.RptId = rptId; else dataAccessError = DataAccessErrorTypeInconsistent; break;
+ case "DATSET": if (state.RptEna) dataAccessError = DataAccessErrorObjectAccessDenied; else if (TryString(value, out var datSet)) state.DatSet = datSet; else dataAccessError = DataAccessErrorTypeInconsistent; break;
+ case "OPTFLDS": if (state.RptEna) dataAccessError = DataAccessErrorObjectAccessDenied; else if (TryBitString(value, 2, out var optFlds)) state.OptFlds = optFlds; else dataAccessError = DataAccessErrorTypeInconsistent; break;
+ case "TRGOPS": if (state.RptEna) dataAccessError = DataAccessErrorObjectAccessDenied; else if (TryBitString(value, 1, out var trgOps)) state.TrgOps = trgOps.Length > 0 ? trgOps[0] : (byte)0; else dataAccessError = DataAccessErrorTypeInconsistent; break;
+ case "BUFTM": if (state.RptEna) dataAccessError = DataAccessErrorObjectAccessDenied; else if (TryUnsigned(value, out var bufTm)) state.BufTm = (uint)Math.Min(bufTm, uint.MaxValue); else dataAccessError = DataAccessErrorTypeInconsistent; break;
+ case "INTGPD": if (state.RptEna) dataAccessError = DataAccessErrorObjectAccessDenied; else if (TryUnsigned(value, out var intgPd)) state.IntgPd = (uint)Math.Min(intgPd, uint.MaxValue); else dataAccessError = DataAccessErrorTypeInconsistent; break;
+ case "ENTRYID": if (value.Kind == MmsDataKind.OctetString) state.EntryId = value.RawValue.ToArray(); else dataAccessError = DataAccessErrorTypeInconsistent; break;
+ case "SQNUM": case "CONFREV": case "TIMEOFENTRY": case "OWNER": dataAccessError = DataAccessErrorObjectAccessDenied; break;
+ default: dataAccessError = DataAccessErrorObjectNonExistent; break;
}
}
-
- if (sendGeneralInterrogation)
- QueueReport(state, ReasonGeneralInterrogation);
-
+ if (seedDataChangeBaseline) SeedDataChangeBaseline(state);
+ if (sendGeneralInterrogation) QueueReport(state, ReasonGeneralInterrogation);
return true;
}
public void Dispose()
{
- if (_disposed)
- return;
-
+ if (_disposed) return;
_disposed = true;
_cts.Cancel();
+ _dataChangeTimer.Dispose();
lock (_gate)
{
- foreach (var timer in _integrityTimers.Values)
- timer.Dispose();
+ foreach (var timer in _integrityTimers.Values) timer.Dispose();
_integrityTimers.Clear();
+ _lastDataSetFingerprints.Clear();
+ _pendingDataChangeReports.Clear();
}
-
_cts.Dispose();
}
- private const byte ReasonGeneralInterrogation = 0x08; // reason bit 4 (GI)
- private const byte ReasonIntegrity = 0x10; // reason bit 3 (integrity)
+ private const byte ReasonDataChange = 0x80;
+ private const byte ReasonGeneralInterrogation = 0x08;
+ private const byte ReasonIntegrity = 0x10;
- private void RestartIntegrityTimerLocked(MmsRcbRuntimeState state)
+ private void SeedDataChangeBaseline(MmsRcbRuntimeState state)
{
- StopIntegrityTimerLocked(state);
- if (!state.RptEna || state.IntgPd == 0 || !MmsReportControlBlockLayout.TriggerIntegrity(state.TrgOps))
- return;
-
- var period = TimeSpan.FromMilliseconds(Math.Max(100, state.IntgPd));
- _integrityTimers[state.MmsReference] = new Timer(_ => QueueReport(state, ReasonIntegrity), null, period, period);
+ if (!MmsReportControlBlockLayout.TriggerDataChange(state.TrgOps)) return;
+ var fingerprint = ReadDataSetFingerprint(state);
+ if (fingerprint is null) return;
+ lock (_gate) _lastDataSetFingerprints[state.MmsReference] = fingerprint;
}
- private void StopIntegrityTimerLocked(MmsRcbRuntimeState state)
+ private void DetectDataChanges()
{
- if (_integrityTimers.Remove(state.MmsReference, out var timer))
- timer.Dispose();
+ if (_disposed || Interlocked.Exchange(ref _dataChangeScanActive, 1) != 0) return;
+ try
+ {
+ MmsRcbRuntimeState[] enabled;
+ lock (_gate) enabled = _states.Values.Where(s => s.RptEna && MmsReportControlBlockLayout.TriggerDataChange(s.TrgOps)).ToArray();
+ foreach (var state in enabled)
+ {
+ var fingerprint = ReadDataSetFingerprint(state);
+ if (fingerprint is null) continue;
+ var changed = false;
+ lock (_gate)
+ {
+ if (!_lastDataSetFingerprints.TryGetValue(state.MmsReference, out var previous)) _lastDataSetFingerprints[state.MmsReference] = fingerprint;
+ else if (!string.Equals(previous, fingerprint, StringComparison.Ordinal))
+ {
+ _lastDataSetFingerprints[state.MmsReference] = fingerprint;
+ changed = true;
+ }
+ }
+ if (changed) QueueDataChangeReport(state);
+ }
+ }
+ finally { Interlocked.Exchange(ref _dataChangeScanActive, 0); }
}
- private void QueueReport(MmsRcbRuntimeState state, byte reason)
+ private string? ReadDataSetFingerprint(MmsRcbRuntimeState state)
{
- if (_disposed)
- return;
+ var response = _sessionFactory().Handle(new MmsReadOnlyServerRequest { Operation = MmsReadOnlyOperation.ReadDataSet, Target = FromMmsReference(state.DatSet) });
+ if (!response.IsSuccess || response.Values.Count == 0) return null;
+ return string.Join("\u001e", response.Values.Select(point => $"{point.Reference}\u001f{point.Value}\u001f{point.Quality}"));
+ }
+ private void QueueDataChangeReport(MmsRcbRuntimeState state)
+ {
+ uint delay;
+ lock (_gate)
+ {
+ if (!state.RptEna || !_pendingDataChangeReports.Add(state.MmsReference)) return;
+ delay = state.BufTm;
+ }
_ = Task.Run(async () =>
{
try
{
- await SendReportAsync(state, reason, _cts.Token).ConfigureAwait(false);
- }
- catch (OperationCanceledException)
- {
- // Association closing.
+ if (delay > 0) await Task.Delay(TimeSpan.FromMilliseconds(delay), _cts.Token).ConfigureAwait(false);
+ lock (_gate) _pendingDataChangeReports.Remove(state.MmsReference);
+ await SendReportAsync(state, ReasonDataChange, _cts.Token).ConfigureAwait(false);
}
+ catch (OperationCanceledException) { }
catch (Exception ex) when (ex is IOException or ObjectDisposedException or System.Net.Sockets.SocketException or InvalidOperationException)
- {
- _activity?.Invoke(state.MmsReference, false, $"InformationReport send failed: {ex.Message}");
- }
+ { _activity?.Invoke(state.MmsReference, false, $"Data-change report send failed: {ex.Message}"); }
+ }, CancellationToken.None);
+ }
+
+ private void RestartIntegrityTimerLocked(MmsRcbRuntimeState state)
+ {
+ StopIntegrityTimerLocked(state);
+ if (!state.RptEna || state.IntgPd == 0 || !MmsReportControlBlockLayout.TriggerIntegrity(state.TrgOps)) return;
+ var period = TimeSpan.FromMilliseconds(Math.Max(100, state.IntgPd));
+ _integrityTimers[state.MmsReference] = new Timer(_ => QueueReport(state, ReasonIntegrity), null, period, period);
+ }
+
+ private void StopIntegrityTimerLocked(MmsRcbRuntimeState state)
+ { if (_integrityTimers.Remove(state.MmsReference, out var timer)) timer.Dispose(); }
+
+ private void QueueReport(MmsRcbRuntimeState state, byte reason)
+ {
+ if (_disposed) return;
+ _ = Task.Run(async () =>
+ {
+ try { await SendReportAsync(state, reason, _cts.Token).ConfigureAwait(false); }
+ catch (OperationCanceledException) { }
+ catch (Exception ex) when (ex is IOException or ObjectDisposedException or System.Net.Sockets.SocketException or InvalidOperationException)
+ { _activity?.Invoke(state.MmsReference, false, $"InformationReport send failed: {ex.Message}"); }
}, CancellationToken.None);
}
@@ -471,27 +371,14 @@ private async Task SendReportAsync(MmsRcbRuntimeState state, byte reason, Cancel
byte[] payload;
int memberCount;
string rptId;
- lock (_gate)
- {
- if (!state.RptEna)
- return;
- rptId = state.RptId;
- }
-
+ lock (_gate) { if (!state.RptEna) return; rptId = state.RptId; }
var session = _sessionFactory();
- var dataSetResponse = session.Handle(new MmsReadOnlyServerRequest
- {
- Operation = MmsReadOnlyOperation.ReadDataSet,
- Target = FromMmsReference(state.DatSet)
- });
-
+ var dataSetResponse = session.Handle(new MmsReadOnlyServerRequest { Operation = MmsReadOnlyOperation.ReadDataSet, Target = FromMmsReference(state.DatSet) });
if (!dataSetResponse.IsSuccess || dataSetResponse.Values.Count == 0)
{
- _activity?.Invoke(state.MmsReference, false,
- $"InformationReport skipped: DataSet '{state.DatSet}' unresolved ({dataSetResponse.Message}).");
+ _activity?.Invoke(state.MmsReference, false, $"InformationReport skipped: DataSet '{state.DatSet}' unresolved ({dataSetResponse.Message}).");
return;
}
-
lock (_gate)
{
state.SqNum = state.Definition.Buffered ? (state.SqNum + 1) & 0xFFFF : (state.SqNum + 1) & 0xFF;
@@ -499,57 +386,31 @@ private async Task SendReportAsync(MmsRcbRuntimeState state, byte reason, Cancel
if (state.Definition.Buffered)
{
var stamp = state.TimeOfEntry.ToUnixTimeMilliseconds();
- for (var i = 0; i < 8; i++)
- state.EntryId[7 - i] = (byte)(stamp >> (8 * i));
+ for (var i = 0; i < 8; i++) state.EntryId[7 - i] = (byte)(stamp >> (8 * i));
}
-
memberCount = dataSetResponse.Values.Count;
payload = EncodeInformationReport(state, reason, dataSetResponse.Values, dataSetResponse.Items);
}
-
await _sendPresentationPayload(payload, cancellationToken).ConfigureAwait(false);
- _activity?.Invoke(state.MmsReference, true,
- $"InformationReport sent: rptId='{rptId}' reason={(reason == ReasonGeneralInterrogation ? "GI" : "integrity")} members={memberCount.ToString(CultureInfo.InvariantCulture)} sqNum={state.SqNum.ToString(CultureInfo.InvariantCulture)}.");
+ _activity?.Invoke(state.MmsReference, true, $"InformationReport sent: rptId='{rptId}' reason={ReasonName(reason)} members={memberCount.ToString(CultureInfo.InvariantCulture)} sqNum={state.SqNum.ToString(CultureInfo.InvariantCulture)}.");
}
- private byte[] EncodeInformationReport(
- MmsRcbRuntimeState state,
- byte reason,
- IReadOnlyList members,
- IReadOnlyList memberReferences)
- {
- var entries = new List
- {
- MmsDataCodec.Encode(MmsDataValue.VisibleString(state.RptId)),
- MmsDataCodec.Encode(MmsDataValue.BitString(6, state.OptFlds))
- };
-
- if (MmsReportControlBlockLayout.OptionalFieldSequenceNumber(state.OptFlds))
- entries.Add(MmsDataCodec.Encode(MmsDataValue.Unsigned(state.SqNum)));
-
- if (MmsReportControlBlockLayout.OptionalFieldTimeStamp(state.OptFlds))
- entries.Add(MmsDataCodec.Encode(MmsDataValue.BinaryTime(MmsReportControlBlockLayout.ToBinaryTime6(state.TimeOfEntry))));
-
- if (MmsReportControlBlockLayout.OptionalFieldDataSet(state.OptFlds))
- entries.Add(MmsDataCodec.Encode(MmsDataValue.VisibleString(state.DatSet)));
-
- if (MmsReportControlBlockLayout.OptionalFieldBufferOverflow(state.OptFlds) && state.Definition.Buffered)
- entries.Add(MmsDataCodec.Encode(MmsDataValue.Boolean(false)));
+ private static string ReasonName(byte reason) => reason switch
+ { ReasonDataChange => "data-change", ReasonGeneralInterrogation => "GI", ReasonIntegrity => "integrity", _ => $"0x{reason:X2}" };
- if (MmsReportControlBlockLayout.OptionalFieldEntryId(state.OptFlds) && state.Definition.Buffered)
- entries.Add(MmsDataCodec.Encode(MmsDataValue.OctetString(state.EntryId)));
-
- if (MmsReportControlBlockLayout.OptionalFieldConfRev(state.OptFlds))
- entries.Add(MmsDataCodec.Encode(MmsDataValue.Unsigned(state.ConfRev)));
-
- // Inclusion-bitstring: one bit per DataSet member, all set for GI/integrity snapshots.
+ private byte[] EncodeInformationReport(MmsRcbRuntimeState state, byte reason, IReadOnlyList members, IReadOnlyList memberReferences)
+ {
+ var entries = new List { MmsDataCodec.Encode(MmsDataValue.VisibleString(state.RptId)), MmsDataCodec.Encode(MmsDataValue.BitString(6, state.OptFlds)) };
+ if (MmsReportControlBlockLayout.OptionalFieldSequenceNumber(state.OptFlds)) entries.Add(MmsDataCodec.Encode(MmsDataValue.Unsigned(state.SqNum)));
+ if (MmsReportControlBlockLayout.OptionalFieldTimeStamp(state.OptFlds)) entries.Add(MmsDataCodec.Encode(MmsDataValue.BinaryTime(MmsReportControlBlockLayout.ToBinaryTime6(state.TimeOfEntry))));
+ if (MmsReportControlBlockLayout.OptionalFieldDataSet(state.OptFlds)) entries.Add(MmsDataCodec.Encode(MmsDataValue.VisibleString(state.DatSet)));
+ if (MmsReportControlBlockLayout.OptionalFieldBufferOverflow(state.OptFlds) && state.Definition.Buffered) entries.Add(MmsDataCodec.Encode(MmsDataValue.Boolean(false)));
+ if (MmsReportControlBlockLayout.OptionalFieldEntryId(state.OptFlds) && state.Definition.Buffered) entries.Add(MmsDataCodec.Encode(MmsDataValue.OctetString(state.EntryId)));
+ if (MmsReportControlBlockLayout.OptionalFieldConfRev(state.OptFlds)) entries.Add(MmsDataCodec.Encode(MmsDataValue.Unsigned(state.ConfRev)));
var memberCount = members.Count;
var inclusionBytes = new byte[(memberCount + 7) / 8];
- for (var i = 0; i < memberCount; i++)
- inclusionBytes[i / 8] |= (byte)(0x80 >> (i % 8));
- var unusedInclusionBits = (byte)(inclusionBytes.Length * 8 - memberCount);
- entries.Add(MmsDataCodec.Encode(MmsDataValue.BitString(unusedInclusionBits, inclusionBytes)));
-
+ for (var i = 0; i < memberCount; i++) inclusionBytes[i / 8] |= (byte)(0x80 >> (i % 8));
+ entries.Add(MmsDataCodec.Encode(MmsDataValue.BitString((byte)(inclusionBytes.Length * 8 - memberCount), inclusionBytes)));
if (MmsReportControlBlockLayout.OptionalFieldDataReference(state.OptFlds))
{
for (var i = 0; i < memberCount; i++)
@@ -558,26 +419,13 @@ private byte[] EncodeInformationReport(
entries.Add(MmsDataCodec.Encode(MmsDataValue.VisibleString(reference)));
}
}
-
- foreach (var member in members)
- entries.Add(MmsConfirmedRequestBerDispatcher.EncodePointAccessResult(member));
-
+ foreach (var member in members) entries.Add(MmsConfirmedRequestBerDispatcher.EncodePointAccessResult(member));
if (MmsReportControlBlockLayout.OptionalFieldReasonCode(state.OptFlds))
- {
- for (var i = 0; i < memberCount; i++)
- entries.Add(MmsDataCodec.Encode(MmsDataValue.BitString(2, [reason])));
- }
-
- // InformationReport ::= SEQUENCE {
- // variableAccessSpecification CHOICE { variableListName [1] ObjectName { vmd-specific [0] "RPT" } },
- // listOfAccessResult [0] IMPLICIT SEQUENCE OF AccessResult }
+ for (var i = 0; i < memberCount; i++) entries.Add(MmsDataCodec.Encode(MmsDataValue.BitString(2, [reason])));
var vmdSpecificRpt = BerWriter.EncodeTlv(0x80, BerWriter.EncodeAscii("RPT"));
var variableListName = BerWriter.EncodeTlv(0xA1, vmdSpecificRpt);
var listOfAccessResult = BerWriter.EncodeTlv(0xA0, ConcatAll(entries));
var informationReport = ConcatAll([variableListName, listOfAccessResult]);
-
- // UnconfirmedService ::= CHOICE { informationReport [0] IMPLICIT InformationReport }
- // Unconfirmed-PDU ::= [3] IMPLICIT SEQUENCE { unconfirmedService }
var unconfirmedService = BerWriter.EncodeTlv(0xA0, informationReport);
var unconfirmedPdu = BerWriter.EncodeTlv(0xA3, unconfirmedService);
return MmsPresentation.WrapIsoPresentationPData(unconfirmedPdu, _presentationContextId);
@@ -586,52 +434,30 @@ private byte[] EncodeInformationReport(
private MmsDataValue AttributeValue(MmsRcbRuntimeState state, string attribute)
=> attribute.ToUpperInvariant() switch
{
- "RPTID" => MmsDataValue.VisibleString(state.RptId),
- "RPTENA" => MmsDataValue.Boolean(state.RptEna),
- "RESV" => MmsDataValue.Boolean(state.Resv),
- "RESVTMS" => MmsDataValue.Integer(state.ResvTms),
- "DATSET" => MmsDataValue.VisibleString(state.DatSet),
- "CONFREV" => MmsDataValue.Unsigned(state.ConfRev),
- "OPTFLDS" => MmsDataValue.BitString(6, state.OptFlds),
- "BUFTM" => MmsDataValue.Unsigned(state.BufTm),
- "SQNUM" => MmsDataValue.Unsigned(state.SqNum),
- "TRGOPS" => MmsDataValue.BitString(2, [state.TrgOps]),
- "INTGPD" => MmsDataValue.Unsigned(state.IntgPd),
- "GI" => MmsDataValue.Boolean(false),
- "PURGEBUF" => MmsDataValue.Boolean(state.PurgeBuf),
- "ENTRYID" => MmsDataValue.OctetString(state.EntryId),
+ "RPTID" => MmsDataValue.VisibleString(state.RptId), "RPTENA" => MmsDataValue.Boolean(state.RptEna),
+ "RESV" => MmsDataValue.Boolean(state.Resv), "RESVTMS" => MmsDataValue.Integer(state.ResvTms),
+ "DATSET" => MmsDataValue.VisibleString(state.DatSet), "CONFREV" => MmsDataValue.Unsigned(state.ConfRev),
+ "OPTFLDS" => MmsDataValue.BitString(6, state.OptFlds), "BUFTM" => MmsDataValue.Unsigned(state.BufTm),
+ "SQNUM" => MmsDataValue.Unsigned(state.SqNum), "TRGOPS" => MmsDataValue.BitString(2, [state.TrgOps]),
+ "INTGPD" => MmsDataValue.Unsigned(state.IntgPd), "GI" => MmsDataValue.Boolean(false),
+ "PURGEBUF" => MmsDataValue.Boolean(state.PurgeBuf), "ENTRYID" => MmsDataValue.OctetString(state.EntryId),
"TIMEOFENTRY" => MmsDataValue.BinaryTime(MmsReportControlBlockLayout.ToBinaryTime6(state.TimeOfEntry)),
_ => MmsDataValue.Boolean(false)
};
private bool TryResolve(string iecTarget, out MmsRcbRuntimeState state, out string attribute)
{
- state = null!;
- attribute = string.Empty;
+ state = null!; attribute = string.Empty;
var mmsTarget = ToMmsReference(iecTarget);
- if (string.IsNullOrWhiteSpace(mmsTarget))
- return false;
-
- if (_states.TryGetValue(mmsTarget, out var exact))
- {
- state = exact;
- return true;
- }
-
+ if (string.IsNullOrWhiteSpace(mmsTarget)) return false;
+ if (_states.TryGetValue(mmsTarget, out var exact)) { state = exact; return true; }
foreach (var candidate in _states.Values)
{
- if (!mmsTarget.StartsWith(candidate.MmsReference + "$", StringComparison.OrdinalIgnoreCase))
- continue;
-
+ if (!mmsTarget.StartsWith(candidate.MmsReference + "$", StringComparison.OrdinalIgnoreCase)) continue;
var remainder = mmsTarget[(candidate.MmsReference.Length + 1)..];
- if (remainder.Contains('$', StringComparison.Ordinal))
- return false; // RCB attributes are single-level.
-
- state = candidate;
- attribute = remainder;
- return true;
+ if (remainder.Contains('$', StringComparison.Ordinal)) return false;
+ state = candidate; attribute = remainder; return true;
}
-
return false;
}
@@ -639,47 +465,29 @@ private static string ToMmsReference(string reference)
{
var normalized = (reference ?? string.Empty).Trim();
var slash = normalized.IndexOf('/');
- if (slash < 0)
- return normalized.Replace('.', '$');
-
- return normalized[..slash] + "/" + normalized[(slash + 1)..].Replace('.', '$');
+ return slash < 0 ? normalized.Replace('.', '$') : normalized[..slash] + "/" + normalized[(slash + 1)..].Replace('.', '$');
}
private static string FromMmsReference(string reference)
{
var normalized = (reference ?? string.Empty).Trim();
var slash = normalized.IndexOf('/');
- if (slash < 0)
- return normalized.Replace('$', '.');
-
- return normalized[..slash] + "/" + normalized[(slash + 1)..].Replace('$', '.');
+ return slash < 0 ? normalized.Replace('$', '.') : normalized[..slash] + "/" + normalized[(slash + 1)..].Replace('$', '.');
}
private static bool TryBoolean(MmsDataValue value, out bool result)
- {
- result = value.Kind == MmsDataKind.Boolean && value.Value is bool b && b;
- return value.Kind == MmsDataKind.Boolean;
- }
+ { result = value.Kind == MmsDataKind.Boolean && value.Value is bool b && b; return value.Kind == MmsDataKind.Boolean; }
private static bool TryString(MmsDataValue value, out string result)
- {
- result = value.Value as string ?? string.Empty;
- return value.Kind is MmsDataKind.VisibleString or MmsDataKind.MmsString;
- }
+ { result = value.Value as string ?? string.Empty; return value.Kind is MmsDataKind.VisibleString or MmsDataKind.MmsString; }
private static bool TryUnsigned(MmsDataValue value, out ulong result)
{
switch (value.Kind)
{
- case MmsDataKind.Unsigned when value.Value is ulong u:
- result = u;
- return true;
- case MmsDataKind.Integer when value.Value is long s && s >= 0:
- result = (ulong)s;
- return true;
- default:
- result = 0;
- return false;
+ case MmsDataKind.Unsigned when value.Value is ulong u: result = u; return true;
+ case MmsDataKind.Integer when value.Value is long s && s >= 0: result = (ulong)s; return true;
+ default: result = 0; return false;
}
}
@@ -687,31 +495,19 @@ private static bool TrySigned(MmsDataValue value, out long result)
{
switch (value.Kind)
{
- case MmsDataKind.Integer when value.Value is long s:
- result = s;
- return true;
- case MmsDataKind.Unsigned when value.Value is ulong u && u <= long.MaxValue:
- result = (long)u;
- return true;
- default:
- result = 0;
- return false;
+ case MmsDataKind.Integer when value.Value is long s: result = s; return true;
+ case MmsDataKind.Unsigned when value.Value is ulong u && u <= long.MaxValue: result = (long)u; return true;
+ default: result = 0; return false;
}
}
private static bool TryBitString(MmsDataValue value, int minimumBytes, out byte[] result)
{
result = System.Array.Empty();
- if (value.Kind != MmsDataKind.BitString || value.RawValue.Count < 1)
- return false;
-
- // RawValue = [unusedBits][data...]
+ if (value.Kind != MmsDataKind.BitString || value.RawValue.Count < 1) return false;
var data = value.RawValue.Skip(1).ToArray();
- if (data.Length < minimumBytes)
- data = data.Concat(Enumerable.Repeat((byte)0, minimumBytes - data.Length)).ToArray();
-
- result = data;
- return true;
+ if (data.Length < minimumBytes) data = data.Concat(Enumerable.Repeat((byte)0, minimumBytes - data.Length)).ToArray();
+ result = data; return true;
}
private static byte[] ConcatAll(IReadOnlyList parts)
@@ -719,12 +515,7 @@ private static byte[] ConcatAll(IReadOnlyList parts)
var total = parts.Sum(x => x.Length);
var buffer = new byte[total];
var offset = 0;
- foreach (var part in parts)
- {
- Buffer.BlockCopy(part, 0, buffer, offset, part.Length);
- offset += part.Length;
- }
-
+ foreach (var part in parts) { Buffer.BlockCopy(part, 0, buffer, offset, part.Length); offset += part.Length; }
return buffer;
}
}
diff --git a/tests/AR.Iec61850.Tests/MmsCompositeAssociationRuntimeTests.cs b/tests/AR.Iec61850.Tests/MmsCompositeAssociationRuntimeTests.cs
new file mode 100644
index 0000000..2482f7f
--- /dev/null
+++ b/tests/AR.Iec61850.Tests/MmsCompositeAssociationRuntimeTests.cs
@@ -0,0 +1,90 @@
+using AR.Iec61850.Mms;
+using AR.Iec61850.Simulation;
+
+namespace AR.Iec61850.Tests;
+
+public sealed class MmsCompositeAssociationRuntimeTests
+{
+ [Fact]
+ public void Write_FallsThroughToApplicationRuntime()
+ {
+ using var reporting = new StubRuntime();
+ using var process = new StubRuntime
+ {
+ WriteHandler = (target, value) => target.EndsWith("$Oper", StringComparison.OrdinalIgnoreCase)
+ ? (true, 0)
+ : (false, 0)
+ };
+ using var composite = new MmsCompositeAssociationRuntime(reporting, process);
+
+ var handled = composite.TryWriteRcbAttribute(
+ "ARVAVR1/YLTC1$CO$TapChg$Oper",
+ MmsDataValue.Structure([MmsDataValue.Integer(2)]),
+ out var error);
+
+ Assert.True(handled);
+ Assert.Equal(0, error);
+ Assert.Equal(1, process.WriteCount);
+ }
+
+ [Fact]
+ public void Read_FirstClaimingRuntimeWins()
+ {
+ using var first = new StubRuntime
+ {
+ ReadHandler = target => target == "owned"
+ ? (true, MmsDataValue.VisibleString("first"))
+ : (false, MmsDataValue.Boolean(false))
+ };
+ using var second = new StubRuntime
+ {
+ ReadHandler = _ => (true, MmsDataValue.VisibleString("second"))
+ };
+ using var composite = new MmsCompositeAssociationRuntime(first, second);
+
+ Assert.True(composite.TryReadRcbAttribute("owned", out var value));
+ Assert.Equal("first", value.Value);
+ Assert.Equal(0, second.ReadCount);
+ }
+
+ [Fact]
+ public void Dispose_DisposesOwnedRuntimesExactlyOnce()
+ {
+ var first = new StubRuntime();
+ var second = new StubRuntime();
+ var composite = new MmsCompositeAssociationRuntime(first, second);
+
+ composite.Dispose();
+ composite.Dispose();
+
+ Assert.Equal(1, first.DisposeCount);
+ Assert.Equal(1, second.DisposeCount);
+ }
+
+ private sealed class StubRuntime : IMmsAssociationRuntime, IDisposable
+ {
+ public Func? ReadHandler { get; init; }
+ public Func? WriteHandler { get; init; }
+ public int ReadCount { get; private set; }
+ public int WriteCount { get; private set; }
+ public int DisposeCount { get; private set; }
+
+ public bool TryReadRcbAttribute(string iecTarget, out MmsDataValue value)
+ {
+ ReadCount++;
+ var result = ReadHandler?.Invoke(iecTarget) ?? (false, MmsDataValue.Boolean(false));
+ value = result.value;
+ return result.handled;
+ }
+
+ public bool TryWriteRcbAttribute(string iecTarget, MmsDataValue value, out int dataAccessError)
+ {
+ WriteCount++;
+ var result = WriteHandler?.Invoke(iecTarget, value) ?? (false, 0);
+ dataAccessError = result.error;
+ return result.handled;
+ }
+
+ public void Dispose() => DisposeCount++;
+ }
+}
diff --git a/tests/AR.Iec61850.Tests/Simulation/MmsReportingRuntimeTests.cs b/tests/AR.Iec61850.Tests/Simulation/MmsReportingRuntimeTests.cs
index 059f3fe..290bae0 100644
--- a/tests/AR.Iec61850.Tests/Simulation/MmsReportingRuntimeTests.cs
+++ b/tests/AR.Iec61850.Tests/Simulation/MmsReportingRuntimeTests.cs
@@ -93,7 +93,6 @@ public void Dispatcher_MultiVariableReadReturnsOneAccessResultPerVariable()
[Fact]
public async Task ReportingRuntime_RequiresExplicitGiAndEmitsStandardReasonBits()
{
- var session = CreateSession();
var sentReport = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using var runtime = new MmsAssociationReportingRuntime(
() => CreateSession(),
@@ -128,6 +127,55 @@ public async Task ReportingRuntime_RequiresExplicitGiAndEmitsStandardReasonBits(
Assert.Equal((byte)0x08, reason.RawValue[1]);
}
+ [Fact]
+ public async Task ReportingRuntime_DataChangeTriggerEmitsUnsolicitedReportWithoutClientPolling()
+ {
+ var simulatorProfile = IedSimulatorProfile.CreateDefaultFeederProfile();
+ var engine = new IedSimulatorEngine(simulatorProfile);
+ var builder = new MmsReadOnlyServerModelBuilder();
+ MmsReadOnlyServerSession SessionFactory()
+ => new(builder.Build(simulatorProfile, engine.CreateSnapshot(DateTimeOffset.UtcNow)));
+
+ var sentReport = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using var runtime = new MmsAssociationReportingRuntime(
+ SessionFactory,
+ (payload, _) =>
+ {
+ sentReport.TrySetResult(payload);
+ return Task.CompletedTask;
+ });
+ var state = runtime.States.First(candidate =>
+ MmsReportControlBlockLayout.TriggerDataChange(candidate.TrgOps));
+
+ Assert.True(runtime.TryWriteRcbAttribute(
+ $"{state.MmsReference}$RptEna",
+ MmsDataValue.Boolean(true),
+ out var enableError));
+ Assert.Equal(0, enableError);
+
+ await Task.Delay(120); // allow baseline capture; no report should be generated by enabling alone.
+ Assert.False(sentReport.Task.IsCompleted);
+
+ var dataSet = simulatorProfile.DataSets.Single(ds =>
+ string.Equals(ds.Reference, state.Definition.DataSetReference, StringComparison.OrdinalIgnoreCase));
+ var member = dataSet.Members[0];
+ var slash = member.IndexOf('/');
+ var pointReference = slash >= 0 ? member[(slash + 1)..] : member;
+ Assert.True(engine.TryGetPointState(pointReference, out var pointState));
+ pointState.Value = string.Equals(pointState.Value, "true", StringComparison.OrdinalIgnoreCase) ? "false" : "987.654";
+ pointState.Quality = "valid";
+ pointState.TimestampUtc = DateTimeOffset.UtcNow;
+ pointState.Reason = "data-change";
+
+ var payload = await sentReport.Task.WaitAsync(TimeSpan.FromSeconds(3));
+ var report = MmsInformationReportDecoder.Decode(payload);
+ var reason = report.Items.Last(item => item.Value?.Kind == MmsDataKind.BitString).Value!;
+
+ Assert.True(report.IsSuccess, report.Message);
+ Assert.Equal((byte)2, reason.RawValue[0]);
+ Assert.Equal((byte)0x80, reason.RawValue[1]);
+ }
+
[Fact]
public void ReportControlBlockLayout_UsesExpectedPackedListWidths()
{
@@ -138,6 +186,7 @@ public void ReportControlBlockLayout_UsesExpectedPackedListWidths()
Assert.Equal(new byte[] { 0x7F, 0xC0 }, optionalFields);
Assert.Equal((byte)0x7C, triggerOptions);
+ Assert.True(MmsReportControlBlockLayout.TriggerDataChange(triggerOptions));
}
private static MmsReadOnlyServerSession CreateSession()