From 8a732688eb58fd778af10f9d825fabb069ede317 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 7 Aug 2026 18:40:52 +0700 Subject: [PATCH 1/6] 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/6] 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/6] 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++; + } +} From 2f8bff1f0c44fa0cf182a9362e178444daf6a44b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 05:54:57 +0700 Subject: [PATCH 4/6] fix: emit data-change reports for enabled RCBs --- .../MmsServer/MmsReportingRuntime.cs | 369 ++++++++---------- 1 file changed, 171 insertions(+), 198 deletions(-) diff --git a/src/AR.Iec61850.Simulation/MmsServer/MmsReportingRuntime.cs b/src/AR.Iec61850.Simulation/MmsServer/MmsReportingRuntime.cs index 87f4a9e..d52a003 100644 --- a/src/AR.Iec61850.Simulation/MmsServer/MmsReportingRuntime.cs +++ b/src/AR.Iec61850.Simulation/MmsServer/MmsReportingRuntime.cs @@ -120,6 +120,9 @@ public static byte ParseTriggerOptions(string tokens) 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; @@ -150,10 +153,7 @@ private static IEnumerable SplitTokens(string tokens) 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 +171,9 @@ 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 continuously observes enabled +/// DataSets and emits unsolicited data-change reports when a member value/quality changes. The +/// observer is server-side; clients such as IEDScout do not need to poll process values. /// public sealed class MmsAssociationReportingRuntime : IMmsAssociationRuntime, IDisposable { @@ -183,6 +181,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,8 +189,12 @@ 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( @@ -226,6 +229,8 @@ public MmsAssociationReportingRuntime( IntgPd = (uint)Math.Max(0, rcb.IntegrityPeriodMs) }; } + + _dataChangeTimer = new Timer(_ => DetectDataChanges(), null, DataChangeScanPeriodMs, DataChangeScanPeriodMs); } public IReadOnlyCollection States => _states.Values; @@ -263,6 +268,7 @@ public bool TryWriteRcbAttribute(string iecTarget, MmsDataValue value, out int d return false; var sendGeneralInterrogation = false; + var seedDataChangeBaseline = false; lock (_gate) { switch (attribute.ToUpperInvariant()) @@ -279,13 +285,15 @@ public bool TryWriteRcbAttribute(string iecTarget, MmsDataValue value, out int d 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": @@ -298,19 +306,13 @@ public bool TryWriteRcbAttribute(string iecTarget, MmsDataValue value, out int d break; case "RESV": - if (TryBoolean(value, out var resv)) - state.Resv = resv; - else - dataAccessError = DataAccessErrorTypeInconsistent; + 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; + 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)) { @@ -321,144 +323,177 @@ public bool TryWriteRcbAttribute(string iecTarget, MmsDataValue value, out int d System.Array.Clear(state.EntryId, 0, state.EntryId.Length); } } - else - { - dataAccessError = DataAccessErrorTypeInconsistent; - } - + else dataAccessError = DataAccessErrorTypeInconsistent; break; - case "RPTID": - if (state.RptEna) - dataAccessError = DataAccessErrorObjectAccessDenied; - else if (TryString(value, out var rptId)) - state.RptId = rptId; - else - dataAccessError = DataAccessErrorTypeInconsistent; + 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; + 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; + 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; + 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; + 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; + 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; + 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 (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); + 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 (OperationCanceledException) { } + catch (Exception ex) when (ex is IOException or ObjectDisposedException or System.Net.Sockets.SocketException or InvalidOperationException) { - // Association closing. + _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}"); @@ -473,8 +508,7 @@ private async Task SendReportAsync(MmsRcbRuntimeState state, byte reason, Cancel string rptId; lock (_gate) { - if (!state.RptEna) - return; + if (!state.RptEna) return; rptId = state.RptId; } @@ -484,11 +518,9 @@ private async Task SendReportAsync(MmsRcbRuntimeState state, byte reason, Cancel 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; } @@ -499,56 +531,43 @@ 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)}."); + $"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) + private static string ReasonName(byte reason) => reason switch + { + ReasonDataChange => "data-change", + ReasonGeneralInterrogation => "GI", + ReasonIntegrity => "integrity", + _ => $"0x{reason:X2}" + }; + + 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))); - 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))); - - // Inclusion-bitstring: one bit per DataSet member, all set for GI/integrity snapshots. 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)) { @@ -558,26 +577,14 @@ 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]))); - } + 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 } 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); @@ -609,29 +616,17 @@ private bool TryResolve(string iecTarget, out MmsRcbRuntimeState state, out stri 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. - + if (remainder.Contains('$', StringComparison.Ordinal)) return false; state = candidate; attribute = remainder; return true; } - return false; } @@ -639,9 +634,7 @@ private static string ToMmsReference(string reference) { var normalized = (reference ?? string.Empty).Trim(); var slash = normalized.IndexOf('/'); - if (slash < 0) - return normalized.Replace('.', '$'); - + if (slash < 0) return normalized.Replace('.', '$'); return normalized[..slash] + "/" + normalized[(slash + 1)..].Replace('.', '$'); } @@ -649,9 +642,7 @@ private static string FromMmsReference(string reference) { var normalized = (reference ?? string.Empty).Trim(); var slash = normalized.IndexOf('/'); - if (slash < 0) - return normalized.Replace('$', '.'); - + if (slash < 0) return normalized.Replace('$', '.'); return normalized[..slash] + "/" + normalized[(slash + 1)..].Replace('$', '.'); } @@ -671,15 +662,9 @@ 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,29 +672,18 @@ 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(); - + if (data.Length < minimumBytes) data = data.Concat(Enumerable.Repeat((byte)0, minimumBytes - data.Length)).ToArray(); result = data; return true; } @@ -724,7 +698,6 @@ private static byte[] ConcatAll(IReadOnlyList parts) Buffer.BlockCopy(part, 0, buffer, offset, part.Length); offset += part.Length; } - return buffer; } -} +} \ No newline at end of file From 6b4577a2feef1ceaefc48eed5fc1196dffe73cc5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 05:55:28 +0700 Subject: [PATCH 5/6] test: verify unsolicited data-change reporting --- .../Simulation/MmsReportingRuntimeTests.cs | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) 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() From 0d0aa4e31c17f9e5a10901ad52fa75e9c4581daf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 8 Aug 2026 06:00:29 +0700 Subject: [PATCH 6/6] chore: keep reporting runtime vendor-neutral --- .../MmsServer/MmsReportingRuntime.cs | 320 ++++-------------- 1 file changed, 69 insertions(+), 251 deletions(-) diff --git a/src/AR.Iec61850.Simulation/MmsServer/MmsReportingRuntime.cs b/src/AR.Iec61850.Simulation/MmsServer/MmsReportingRuntime.cs index d52a003..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,6 @@ 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; @@ -130,26 +94,17 @@ 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; } @@ -171,9 +126,10 @@ public sealed class MmsRcbRuntimeState } /// -/// Per-association reporting engine. Besides GI and integrity it continuously observes enabled -/// DataSets and emits unsolicited data-change reports when a member value/quality changes. The -/// observer is server-side; clients such as IEDScout do not need to poll process values. +/// 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 { @@ -197,25 +153,18 @@ public sealed class MmsAssociationReportingRuntime : IMmsAssociationRuntime, IDi 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, @@ -229,7 +178,6 @@ public MmsAssociationReportingRuntime( IntgPd = (uint)Math.Max(0, rcb.IntegrityPeriodMs) }; } - _dataChangeTimer = new Timer(_ => DetectDataChanges(), null, DataChangeScanPeriodMs, DataChangeScanPeriodMs); } @@ -238,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; } @@ -264,9 +204,7 @@ 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) @@ -274,12 +212,7 @@ public bool TryWriteRcbAttribute(string iecTarget, MmsDataValue value, out int d 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; @@ -295,86 +228,34 @@ public bool TryWriteRcbAttribute(string iecTarget, MmsDataValue value, out int d _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; - 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; + 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 "PURGEBUF": if (TryBoolean(value, out var purge)) { state.PurgeBuf = false; - if (purge) - { - state.SqNum = 0; - System.Array.Clear(state.EntryId, 0, state.EntryId.Length); - } + if (purge) { state.SqNum = 0; System.Array.Clear(state.EntryId, 0, state.EntryId.Length); } } 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; + 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 (seedDataChangeBaseline) - SeedDataChangeBaseline(state); - if (sendGeneralInterrogation) - QueueReport(state, ReasonGeneralInterrogation); + if (seedDataChangeBaseline) SeedDataChangeBaseline(state); + if (sendGeneralInterrogation) QueueReport(state, ReasonGeneralInterrogation); return true; } @@ -412,9 +293,7 @@ private void DetectDataChanges() try { MmsRcbRuntimeState[] enabled; - lock (_gate) - enabled = _states.Values.Where(s => s.RptEna && MmsReportControlBlockLayout.TriggerDataChange(s.TrgOps)).ToArray(); - + lock (_gate) enabled = _states.Values.Where(s => s.RptEna && MmsReportControlBlockLayout.TriggerDataChange(s.TrgOps)).ToArray(); foreach (var state in enabled) { var fingerprint = ReadDataSetFingerprint(state); @@ -422,8 +301,7 @@ private void DetectDataChanges() var changed = false; lock (_gate) { - if (!_lastDataSetFingerprints.TryGetValue(state.MmsReference, out var previous)) - _lastDataSetFingerprints[state.MmsReference] = fingerprint; + if (!_lastDataSetFingerprints.TryGetValue(state.MmsReference, out var previous)) _lastDataSetFingerprints[state.MmsReference] = fingerprint; else if (!string.Equals(previous, fingerprint, StringComparison.Ordinal)) { _lastDataSetFingerprints[state.MmsReference] = fingerprint; @@ -433,19 +311,12 @@ private void DetectDataChanges() if (changed) QueueDataChangeReport(state); } } - finally - { - Interlocked.Exchange(ref _dataChangeScanActive, 0); - } + finally { Interlocked.Exchange(ref _dataChangeScanActive, 0); } } private string? ReadDataSetFingerprint(MmsRcbRuntimeState state) { - var response = _sessionFactory().Handle(new MmsReadOnlyServerRequest - { - Operation = MmsReadOnlyOperation.ReadDataSet, - Target = FromMmsReference(state.DatSet) - }); + 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}")); } @@ -468,9 +339,7 @@ private void QueueDataChangeReport(MmsRcbRuntimeState state) } catch (OperationCanceledException) { } catch (Exception ex) when (ex is IOException or ObjectDisposedException or System.Net.Sockets.SocketException or InvalidOperationException) - { - _activity?.Invoke(state.MmsReference, false, $"Data-change report send failed: {ex.Message}"); - } + { _activity?.Invoke(state.MmsReference, false, $"Data-change report send failed: {ex.Message}"); } }, CancellationToken.None); } @@ -483,9 +352,7 @@ private void RestartIntegrityTimerLocked(MmsRcbRuntimeState state) } private void StopIntegrityTimerLocked(MmsRcbRuntimeState state) - { - if (_integrityTimers.Remove(state.MmsReference, out var timer)) timer.Dispose(); - } + { if (_integrityTimers.Remove(state.MmsReference, out var timer)) timer.Dispose(); } private void QueueReport(MmsRcbRuntimeState state, byte reason) { @@ -495,9 +362,7 @@ private void QueueReport(MmsRcbRuntimeState state, byte reason) 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}"); - } + { _activity?.Invoke(state.MmsReference, false, $"InformationReport send failed: {ex.Message}"); } }, CancellationToken.None); } @@ -506,24 +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})."); return; } - lock (_gate) { state.SqNum = state.Definition.Buffered ? (state.SqNum + 1) & 0xFFFF : (state.SqNum + 1) & 0xFF; @@ -536,39 +391,26 @@ private async Task SendReportAsync(MmsRcbRuntimeState state, byte reason, Cancel 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={ReasonName(reason)} 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 static string ReasonName(byte reason) => reason switch - { - ReasonDataChange => "data-change", - ReasonGeneralInterrogation => "GI", - ReasonIntegrity => "integrity", - _ => $"0x{reason:X2}" - }; + { ReasonDataChange => "data-change", ReasonGeneralInterrogation => "GI", ReasonIntegrity => "integrity", _ => $"0x{reason:X2}" }; 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)) - }; + 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)); entries.Add(MmsDataCodec.Encode(MmsDataValue.BitString((byte)(inclusionBytes.Length * 8 - memberCount), inclusionBytes))); - if (MmsReportControlBlockLayout.OptionalFieldDataReference(state.OptFlds)) { for (var i = 0; i < memberCount; i++) @@ -580,7 +422,6 @@ private byte[] EncodeInformationReport(MmsRcbRuntimeState state, byte reason, IR 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]))); - var vmdSpecificRpt = BerWriter.EncodeTlv(0x80, BerWriter.EncodeAscii("RPT")); var variableListName = BerWriter.EncodeTlv(0xA1, vmdSpecificRpt); var listOfAccessResult = BerWriter.EncodeTlv(0xA0, ConcatAll(entries)); @@ -593,28 +434,20 @@ private byte[] EncodeInformationReport(MmsRcbRuntimeState state, byte reason, IR 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; } @@ -623,9 +456,7 @@ private bool TryResolve(string iecTarget, out MmsRcbRuntimeState state, out stri if (!mmsTarget.StartsWith(candidate.MmsReference + "$", StringComparison.OrdinalIgnoreCase)) continue; var remainder = mmsTarget[(candidate.MmsReference.Length + 1)..]; if (remainder.Contains('$', StringComparison.Ordinal)) return false; - state = candidate; - attribute = remainder; - return true; + state = candidate; attribute = remainder; return true; } return false; } @@ -634,29 +465,21 @@ 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) { @@ -684,8 +507,7 @@ private static bool TryBitString(MmsDataValue value, int minimumBytes, out byte[ 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; + result = data; return true; } private static byte[] ConcatAll(IReadOnlyList parts) @@ -693,11 +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; } -} \ No newline at end of file +}