Skip to content
Draft
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
31 changes: 24 additions & 7 deletions src/AR.Iec61850.Simulation/MmsServer/IedSimulatorMmsServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ public sealed class IedSimulatorMmsServerOptions

/// <summary>Maximum number of recent activity records kept in memory for monitoring.</summary>
public int ActivityHistoryLimit { get; init; } = 500;

/// <summary>
/// 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.
/// </summary>
public Func<string, IMmsAssociationRuntime?>? AssociationRuntimeFactory { get; init; }
}

public enum IedSimulatorServerActivityKind
Expand Down Expand Up @@ -59,19 +68,17 @@ public sealed record IedSimulatorServerActivity
}

/// <summary>
/// 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 <see cref="IedSimulatorMmsServerOptions.AssociationRuntimeFactory"/>.
///
/// This is the "Open SCL → Run" capability: combined with <see cref="IedSimulatorProfileBuilder"/> a
/// caller can load any SCL model and serve it. All protocol encode/decode is delegated to the existing
/// tested codecs (<c>TpktFrameCodec</c>, <c>CotpFrameCodec</c>, <c>AcseMmsAssociateResponse</c>) and
/// the <c>MmsConfirmedRequestBerDispatcher</c>; 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.
/// </summary>
public sealed class IedSimulatorMmsServer : IAsyncDisposable
{
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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) { }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using AR.Iec61850.Mms;

namespace AR.Iec61850.Simulation;

/// <summary>
/// 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.
/// </summary>
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();
}
}
}
90 changes: 90 additions & 0 deletions tests/AR.Iec61850.Tests/MmsCompositeAssociationRuntimeTests.cs
Original file line number Diff line number Diff line change
@@ -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<string, (bool handled, MmsDataValue value)>? ReadHandler { get; init; }
public Func<string, MmsDataValue, (bool handled, int error)>? 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++;
}
}
Loading