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/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++;
+ }
+}