From 8a732688eb58fd778af10f9d825fabb069ede317 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 18:40:52 +0700 Subject: [PATCH 1/3] feat: add composable MMS association runtime --- .../MmsCompositeAssociationRuntime.cs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/AR.Iec61850.Simulation/MmsServer/MmsCompositeAssociationRuntime.cs 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(); + } + } +} From f28b34b1ab077b18250697821d3e3b8a72dff46a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 18:42:00 +0700 Subject: [PATCH 2/3] feat: allow application MMS association runtimes --- .../MmsServer/IedSimulatorMmsServer.cs | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) 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) { } From 0e1e2de693bc3f5427f6b1f23fcc6c3e6fe1f60e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 18:42:27 +0700 Subject: [PATCH 3/3] test: cover composable MMS association runtimes --- .../MmsCompositeAssociationRuntimeTests.cs | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/MmsCompositeAssociationRuntimeTests.cs 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++; + } +}