diff --git a/.release/windows.json b/.release/windows.json
index 0ec068fc9..b4ae29d38 100644
--- a/.release/windows.json
+++ b/.release/windows.json
@@ -1,7 +1,7 @@
{
- "version": "1.6.30",
+ "version": "1.6.31",
"channel": "stable",
"platform": "windows-x64",
"primaryAsset": "ARSAS-Windows-x64-Setup.exe",
- "publicationRequest": 18
+ "publicationRequest": 19
}
diff --git a/ArIED61850Tester.csproj b/ArIED61850Tester.csproj
index fe450228c..e8afcea4c 100644
--- a/ArIED61850Tester.csproj
+++ b/ArIED61850Tester.csproj
@@ -15,9 +15,9 @@
ARSAS
ARSAS - IEC 61850 Engineering Workstation
Open-source Windows IEC 61850 engineering workstation for MMS model discovery, reporting, independent multi-IED monitoring, GOOSE subscription, fault-record file transfer, Sampled Values engineering and evidence export, SCL workflows, diagnostics, sequence of events, and guarded control validation.
- 1.6.30
- 1.6.30.0
- 1.6.30.0
+ 1.6.31
+ 1.6.31.0
+ 1.6.31.0
https://github.com/masarray/arsas
https://github.com/masarray/arsas
git
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b0b0664a5..3f6f1dc4c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,14 @@ Notable public changes to ARSAS are recorded here. Application releases must ide
## Unreleased
+## 1.6.31 — 2026-08-15
+
+### Fixed
+
+- Smart reconnect now rebuilds report acquisition through the same ARIEC-authoritative hybrid planner used by initial monitoring, preserving fresh static/dynamic BRCB/URCB assignments instead of silently reverting to legacy planning.
+- Reconnect clears report-traffic, change-verification, command-correlation, and acquisition-label state from the previous MMS association before new plans start, preventing false physical evidence after GI/integrity updates.
+- Hybrid physical-validation snapshots and monitor updates are synchronized, preventing collection-modified exceptions and torn evidence while live report frames are being processed.
+
## 1.6.30 — 2026-08-15
### Added
diff --git a/Directory.Build.props b/Directory.Build.props
index 458c19b67..2f3f0e06c 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,8 +1,8 @@
- 1.6.30
- 1.6.30.0
- 1.6.30.0
- 1.6.30
+ 1.6.31
+ 1.6.31.0
+ 1.6.31.0
+ 1.6.31
diff --git a/Services/HybridReportPhysicalValidationTracker.cs b/Services/HybridReportPhysicalValidationTracker.cs
index 841e959e0..6176695c0 100644
--- a/Services/HybridReportPhysicalValidationTracker.cs
+++ b/Services/HybridReportPhysicalValidationTracker.cs
@@ -28,44 +28,51 @@ private sealed class PlanState
private readonly Dictionary _plans = new(StringComparer.OrdinalIgnoreCase);
private readonly List _warnings = new();
+ private readonly object _sync = new();
private NativeHybridReportPlanningResult? _planning;
public void Reset(NativeHybridReportPlanningResult? planning)
{
- _planning = planning;
- _plans.Clear();
- _warnings.Clear();
+ lock (_sync)
+ {
+ _planning = planning;
+ _plans.Clear();
+ _warnings.Clear();
- if (planning is null)
- return;
+ if (planning is null)
+ return;
- foreach (var warning in planning.Warnings)
- AddWarning(warning);
+ foreach (var warning in planning.Warnings)
+ AddWarning(warning);
- foreach (var plan in planning.ReportPlans)
- _plans[plan.PlanId] = new PlanState { Plan = plan };
+ foreach (var plan in planning.ReportPlans)
+ _plans[plan.PlanId] = new PlanState { Plan = plan };
+ }
}
public void RecordActivation(ReportControlPlan plan, NativeReportMonitorStartResult result)
{
- if (!plan.IsEngineAuthoritative)
- return;
-
- if (!_plans.TryGetValue(plan.PlanId, out var state))
+ lock (_sync)
{
- state = new PlanState { Plan = plan };
- _plans[plan.PlanId] = state;
- }
+ if (!plan.IsEngineAuthoritative)
+ return;
- state.ActivationAttempted = true;
- state.ActivationSucceeded = result.IsSuccess;
- state.ActivationMessage = result.Message;
- state.SubscriptionSummary = result.SubscriptionSummary;
- state.MemberCount = result.MemberCount;
- state.SetupWriteStepCount = result.WriteStepCount;
- state.UsedDynamicDataSet = result.UsedDynamicDataSet;
- foreach (var warning in result.Warnings)
- AddWarning(warning);
+ if (!_plans.TryGetValue(plan.PlanId, out var state))
+ {
+ state = new PlanState { Plan = plan };
+ _plans[plan.PlanId] = state;
+ }
+
+ state.ActivationAttempted = true;
+ state.ActivationSucceeded = result.IsSuccess;
+ state.ActivationMessage = result.Message;
+ state.SubscriptionSummary = result.SubscriptionSummary;
+ state.MemberCount = result.MemberCount;
+ state.SetupWriteStepCount = result.WriteStepCount;
+ state.UsedDynamicDataSet = result.UsedDynamicDataSet;
+ foreach (var warning in result.Warnings)
+ AddWarning(warning);
+ }
}
public void RecordSlice(
@@ -73,87 +80,93 @@ public void RecordSlice(
NativeReportMonitorSliceResult slice,
IEnumerable? changeVerifiedPointKeys = null)
{
- if (!plan.IsEngineAuthoritative || !_plans.TryGetValue(plan.PlanId, out var state))
- return;
-
- state.ReportFrameCount += slice.ReportFrames.Count;
- state.ReportUpdateCount += slice.Updates.Count;
-
- var observedTimes = slice.ReportFrames
- .Select(frame => frame.ReceivedAt)
- .Where(value => value != default)
- .OrderBy(value => value)
- .ToArray();
- if (observedTimes.Length > 0)
+ lock (_sync)
{
- state.FirstReportAtUtc ??= observedTimes[0];
- state.LastReportAtUtc = observedTimes[^1];
- }
- else if (slice.ReportFrames.Count > 0 || slice.Updates.Count > 0)
- {
- var now = DateTimeOffset.UtcNow;
- state.FirstReportAtUtc ??= now;
- state.LastReportAtUtc = now;
- }
+ if (!plan.IsEngineAuthoritative || !_plans.TryGetValue(plan.PlanId, out var state))
+ return;
+
+ state.ReportFrameCount += slice.ReportFrames.Count;
+ state.ReportUpdateCount += slice.Updates.Count;
+
+ var observedTimes = slice.ReportFrames
+ .Select(frame => frame.ReceivedAt)
+ .Where(value => value != default)
+ .OrderBy(value => value)
+ .ToArray();
+ if (observedTimes.Length > 0)
+ {
+ state.FirstReportAtUtc ??= observedTimes[0];
+ state.LastReportAtUtc = observedTimes[^1];
+ }
+ else if (slice.ReportFrames.Count > 0 || slice.Updates.Count > 0)
+ {
+ var now = DateTimeOffset.UtcNow;
+ state.FirstReportAtUtc ??= now;
+ state.LastReportAtUtc = now;
+ }
- if (changeVerifiedPointKeys is not null)
- {
- foreach (var key in changeVerifiedPointKeys.Where(key => !string.IsNullOrWhiteSpace(key)))
- state.ChangeVerifiedPointKeys.Add(key);
- }
+ if (changeVerifiedPointKeys is not null)
+ {
+ foreach (var key in changeVerifiedPointKeys.Where(key => !string.IsNullOrWhiteSpace(key)))
+ state.ChangeVerifiedPointKeys.Add(key);
+ }
- foreach (var warning in slice.Warnings)
- AddWarning(warning);
+ foreach (var warning in slice.Warnings)
+ AddWarning(warning);
+ }
}
public HybridReportPhysicalValidationSnapshot Capture(Iec61850MonitorDevice device)
{
ArgumentNullException.ThrowIfNull(device);
- var planning = _planning;
- var plans = _plans.Values
- .OrderBy(state => state.Plan.EngineAcquisitionKind, StringComparer.OrdinalIgnoreCase)
- .ThenBy(state => state.Plan.ReportControlReference, StringComparer.OrdinalIgnoreCase)
- .Select(state => new HybridReportPhysicalValidationPlan
- {
- PlanId = state.Plan.PlanId,
- AcquisitionKind = state.Plan.EngineAcquisitionKind,
- ReportControlReference = state.Plan.ReportControlReference,
- DataSetReference = state.Plan.DataSetReference,
- PlannedSignalCount = state.Plan.Bindings.Count,
- ActivationSucceeded = state.ActivationSucceeded,
- ActivationMessage = state.ActivationMessage,
- SubscriptionSummary = state.SubscriptionSummary,
- MemberCount = state.MemberCount,
- SetupWriteStepCount = state.SetupWriteStepCount,
- UsedDynamicDataSet = state.UsedDynamicDataSet,
- ReportFrameCount = state.ReportFrameCount,
- ReportUpdateCount = state.ReportUpdateCount,
- ChangeVerifiedPointCount = state.ChangeVerifiedPointKeys.Count,
- FirstReportAtUtc = state.FirstReportAtUtc,
- LastReportAtUtc = state.LastReportAtUtc
- })
- .ToArray();
-
- return new HybridReportPhysicalValidationSnapshot
+ lock (_sync)
{
- CapturedAtUtc = DateTimeOffset.UtcNow,
- DeviceId = device.DeviceId,
- DeviceName = device.Name,
- Endpoint = device.EndpointText,
- PlannedStaticBrcbCount = planning?.StaticBrcbSignalCount ?? 0,
- PlannedStaticUrcbCount = planning?.StaticUrcbSignalCount ?? 0,
- PlannedDynamicBrcbCount = planning?.DynamicBrcbSignalCount ?? 0,
- PlannedDynamicUrcbCount = planning?.DynamicUrcbSignalCount ?? 0,
- ActivatedReportPlanCount = _plans.Values.Count(state => state.ActivationAttempted && state.ActivationSucceeded),
- FailedActivationCount = _plans.Values.Count(state => state.ActivationAttempted && !state.ActivationSucceeded),
- ReportFrameCount = plans.Sum(plan => plan.ReportFrameCount),
- ReportUpdateCount = plans.Sum(plan => plan.ReportUpdateCount),
- ChangeVerifiedPointCount = plans.Sum(plan => plan.ChangeVerifiedPointCount),
- PollingFallbackPointCount = planning?.PollingFallbackSignalCount ?? 0,
- UncoveredPointCount = planning?.UncoveredSignalCount ?? 0,
- Plans = plans,
- Warnings = _warnings.ToArray()
- };
+ var planning = _planning;
+ var plans = _plans.Values
+ .OrderBy(state => state.Plan.EngineAcquisitionKind, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(state => state.Plan.ReportControlReference, StringComparer.OrdinalIgnoreCase)
+ .Select(state => new HybridReportPhysicalValidationPlan
+ {
+ PlanId = state.Plan.PlanId,
+ AcquisitionKind = state.Plan.EngineAcquisitionKind,
+ ReportControlReference = state.Plan.ReportControlReference,
+ DataSetReference = state.Plan.DataSetReference,
+ PlannedSignalCount = state.Plan.Bindings.Count,
+ ActivationSucceeded = state.ActivationSucceeded,
+ ActivationMessage = state.ActivationMessage,
+ SubscriptionSummary = state.SubscriptionSummary,
+ MemberCount = state.MemberCount,
+ SetupWriteStepCount = state.SetupWriteStepCount,
+ UsedDynamicDataSet = state.UsedDynamicDataSet,
+ ReportFrameCount = state.ReportFrameCount,
+ ReportUpdateCount = state.ReportUpdateCount,
+ ChangeVerifiedPointCount = state.ChangeVerifiedPointKeys.Count,
+ FirstReportAtUtc = state.FirstReportAtUtc,
+ LastReportAtUtc = state.LastReportAtUtc
+ })
+ .ToArray();
+
+ return new HybridReportPhysicalValidationSnapshot
+ {
+ CapturedAtUtc = DateTimeOffset.UtcNow,
+ DeviceId = device.DeviceId,
+ DeviceName = device.Name,
+ Endpoint = device.EndpointText,
+ PlannedStaticBrcbCount = planning?.StaticBrcbSignalCount ?? 0,
+ PlannedStaticUrcbCount = planning?.StaticUrcbSignalCount ?? 0,
+ PlannedDynamicBrcbCount = planning?.DynamicBrcbSignalCount ?? 0,
+ PlannedDynamicUrcbCount = planning?.DynamicUrcbSignalCount ?? 0,
+ ActivatedReportPlanCount = _plans.Values.Count(state => state.ActivationAttempted && state.ActivationSucceeded),
+ FailedActivationCount = _plans.Values.Count(state => state.ActivationAttempted && !state.ActivationSucceeded),
+ ReportFrameCount = plans.Sum(plan => plan.ReportFrameCount),
+ ReportUpdateCount = plans.Sum(plan => plan.ReportUpdateCount),
+ ChangeVerifiedPointCount = plans.Sum(plan => plan.ChangeVerifiedPointCount),
+ PollingFallbackPointCount = planning?.PollingFallbackSignalCount ?? 0,
+ UncoveredPointCount = planning?.UncoveredSignalCount ?? 0,
+ Plans = plans,
+ Warnings = _warnings.ToArray()
+ };
+ }
}
private void AddWarning(string? warning)
diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs
index 8faca22fc..294041c7f 100644
--- a/Services/Iec61850MonitorRuntime.cs
+++ b/Services/Iec61850MonitorRuntime.cs
@@ -782,6 +782,21 @@ private async Task TryStartPendingReportSetupAsync(
? "Initial live image is available. Validating static/dynamic report acquisition in the background monitor pipeline."
: "Initial live-image deadline reached. Continuing report validation while MMS fallback remains active.");
+ plans = await BuildReportPlansForCurrentAssociationAsync(
+ session,
+ plans,
+ cancellationToken).ConfigureAwait(false);
+
+ await StartReportPlansAsync(session, plans, cancellationToken).ConfigureAwait(false);
+ ResetPollQueue(session);
+ UpdateDeviceAcquisitionSummary(session);
+ }
+
+ private async Task> BuildReportPlansForCurrentAssociationAsync(
+ DeviceSession session,
+ IReadOnlyList legacyPlans,
+ CancellationToken cancellationToken)
+ {
if (session.Client.CanUseHybridReportPlanner(session.Device))
{
NativeHybridReportPlanningResult hybrid;
@@ -808,22 +823,18 @@ private async Task TryStartPendingReportSetupAsync(
}
session.HybridValidation.Reset(hybrid);
- plans = hybrid.ReportPlans;
Log("INFO", session.Device.Name,
$"Hybrid authority={hybrid.Authority}; status={hybrid.Status}; requested={hybrid.RequestedPointCount}, catalog={hybrid.CatalogMappedPointCount}, staticBRCB={hybrid.StaticBrcbSignalCount}, staticURCB={hybrid.StaticUrcbSignalCount}, dynamicBRCB={hybrid.DynamicBrcbSignalCount}, dynamicURCB={hybrid.DynamicUrcbSignalCount}, polling={hybrid.PollingFallbackSignalCount}, uncovered={hybrid.UncoveredSignalCount}. {hybrid.Summary}");
foreach (var warning in hybrid.Warnings.Take(5))
Log("WARN", session.Device.Name, warning);
- }
- else
- {
- session.HybridValidation.Reset(null);
- Log("INFO", session.Device.Name,
- "ARIEC typed live-model authority is unavailable for this saved/session model; retaining the existing legacy report planner only as compatibility fallback.");
+
+ return hybrid.ReportPlans;
}
- await StartReportPlansAsync(session, plans, cancellationToken).ConfigureAwait(false);
- ResetPollQueue(session);
- UpdateDeviceAcquisitionSummary(session);
+ session.HybridValidation.Reset(null);
+ Log("INFO", session.Device.Name,
+ "ARIEC typed live-model authority is unavailable for this saved/session model; retaining the existing legacy report planner only as compatibility fallback.");
+ return legacyPlans;
}
private void UpdateDeviceAcquisitionSummary(DeviceSession session)
@@ -1415,7 +1426,16 @@ private async Task TryReconnectAsync(DeviceSession session, CancellationToken ca
session.PointPlanIds.Clear();
session.ReportStreams.Clear();
session.LastUnroutedReportCount = 0;
- var plans = Iec61850ReportPlanner.BuildPlans(session.Device, session.Points.Values);
+ session.PendingReportPlans = Array.Empty();
+ session.ReportSetupPending = false;
+ session.ReportSetupNotBeforeUtc = DateTime.MinValue;
+ session.ReportSetupDeadlineUtc = DateTime.MinValue;
+ ResetAssociationReportEvidence(session);
+ var legacyPlans = Iec61850ReportPlanner.BuildPlans(session.Device, session.Points.Values);
+ var plans = await BuildReportPlansForCurrentAssociationAsync(
+ session,
+ legacyPlans,
+ cancellationToken).ConfigureAwait(false);
await StartReportPlansAsync(session, plans, cancellationToken).ConfigureAwait(false);
ResetPollQueue(session);
UpdateDeviceAcquisitionSummary(session);
@@ -1430,6 +1450,28 @@ private async Task TryReconnectAsync(DeviceSession session, CancellationToken ca
Log("INFO", session.Device.Name, "MMS reconnect successful. Monitoring resumed automatically.");
}
+ private static void ResetAssociationReportEvidence(DeviceSession session)
+ {
+ foreach (var state in session.States.Values)
+ {
+ state.ReportTrafficSeen = false;
+ state.ReportChangeVerified = false;
+ state.LastReportUtc = DateTime.MinValue;
+ state.ReportMissLogged = false;
+ state.AwaitingCommandReportEdge = false;
+ state.CommandReportMissLogged = false;
+ state.StaleReportSuppressedLogged = false;
+ state.CommandFeedbackValue = string.Empty;
+ state.LastCommandFeedbackUtc = DateTime.MinValue;
+ state.CommandFeedbackGuardUntilUtc = DateTime.MinValue;
+ state.CommandReportDeadlineUtc = DateTime.MinValue;
+ state.AcquisitionLabel = "MMS polling";
+ state.SourceMode = "Report rearming / MMS polling fallback";
+ state.Reason = "new MMS association / report evidence reset";
+ state.Status = "Reconnected / report rearming";
+ }
+ }
+
private async Task ForceReconnectAsync(DeviceSession session, string reason)
{
MarkSessionOffline(session, reason);
diff --git a/VERSION b/VERSION
index 14781be72..599e1a156 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.6.30
+1.6.31
diff --git a/tests/ARSAS.Tests/HybridReportPhysicalValidationTests.cs b/tests/ARSAS.Tests/HybridReportPhysicalValidationTests.cs
index 4c5c83ca4..79f3d20e4 100644
--- a/tests/ARSAS.Tests/HybridReportPhysicalValidationTests.cs
+++ b/tests/ARSAS.Tests/HybridReportPhysicalValidationTests.cs
@@ -1,3 +1,4 @@
+using System.Collections.Concurrent;
using ArIED61850Tester.Models;
using ArIED61850Tester.Services;
@@ -189,6 +190,85 @@ public void LegacyPlanTraffic_IsNotClaimedAsAriecHybridPhysicalEvidence()
Assert.Equal(0, snapshot.ChangeVerifiedPointCount);
}
+ [Fact]
+ public void ConcurrentMonitorUpdatesAndSnapshots_RemainAtomicAndConsistent()
+ {
+ var plan = EnginePlan("concurrent", "StaticUrcb", "IEDLD0/LLN0.RP.urcb01", "IEDLD0/LLN0.Events", "p-concurrent");
+ var planning = new NativeHybridReportPlanningResult
+ {
+ IsAuthoritative = true,
+ ReportPlans = [plan],
+ StaticUrcbSignalCount = 1,
+ Warnings = ["stable warning"]
+ };
+ var activation = new NativeReportMonitorStartResult
+ {
+ IsSuccess = true,
+ PlanId = plan.PlanId,
+ Message = "Static URCB active"
+ };
+ var slice = new NativeReportMonitorSliceResult
+ {
+ PlanId = plan.PlanId,
+ ReportFrames = [new NativeReportFrameMetadata { ReceivedAt = DateTimeOffset.UtcNow }],
+ Updates = [new NativeReportValueUpdate { Reference = "IEDLD0/GGIO1.Ind1.stVal", Value = "true" }],
+ Warnings = ["slice warning"]
+ };
+ var tracker = new HybridReportPhysicalValidationTracker();
+ tracker.Reset(planning);
+ var errors = new ConcurrentQueue();
+
+ Parallel.For(0, 1000, iteration =>
+ {
+ try
+ {
+ if (iteration % 17 == 0)
+ tracker.Reset(planning);
+ tracker.RecordActivation(plan, activation);
+ tracker.RecordSlice(plan, slice, [$"point-{iteration % 5}"]);
+
+ var snapshot = tracker.Capture(Device());
+ Assert.Equal(snapshot.ReportFrameCount, snapshot.Plans.Sum(item => item.ReportFrameCount));
+ Assert.Equal(snapshot.ReportUpdateCount, snapshot.Plans.Sum(item => item.ReportUpdateCount));
+ Assert.Equal(snapshot.ChangeVerifiedPointCount, snapshot.Plans.Sum(item => item.ChangeVerifiedPointCount));
+ Assert.Equal(snapshot.Warnings.Count, snapshot.Warnings.Distinct(StringComparer.OrdinalIgnoreCase).Count());
+ }
+ catch (Exception ex)
+ {
+ errors.Enqueue(ex);
+ }
+ });
+
+ Assert.Empty(errors);
+ }
+
+ [Fact]
+ public void Reconnect_ReusesTheSameHybridPlanningPipelineAsInitialSetup()
+ {
+ var source = File.ReadAllText(FindRepoFile("Services/Iec61850MonitorRuntime.cs"))
+ .Replace("\r\n", "\n", StringComparison.Ordinal);
+ var reconnectStart = source.IndexOf("private async Task TryReconnectAsync", StringComparison.Ordinal);
+ var reconnectEnd = source.IndexOf("private async Task ForceReconnectAsync", reconnectStart, StringComparison.Ordinal);
+ Assert.True(reconnectStart >= 0 && reconnectEnd > reconnectStart);
+
+ var reconnect = source[reconnectStart..reconnectEnd];
+ Assert.Contains("BuildReportPlansForCurrentAssociationAsync(", reconnect, StringComparison.Ordinal);
+ Assert.Contains("ResetAssociationReportEvidence(session);", reconnect, StringComparison.Ordinal);
+ Assert.Contains("await StartReportPlansAsync(session, plans", reconnect, StringComparison.Ordinal);
+ Assert.True(
+ source.Split("BuildReportPlansForCurrentAssociationAsync(", StringSplitOptions.None).Length - 1 >= 3,
+ "Initial setup and reconnect must both call the shared hybrid planning pipeline.");
+
+ var resetStart = source.IndexOf("private static void ResetAssociationReportEvidence", StringComparison.Ordinal);
+ var resetEnd = source.IndexOf("private async Task ForceReconnectAsync", resetStart, StringComparison.Ordinal);
+ Assert.True(resetStart >= 0 && resetEnd > resetStart);
+ var reset = source[resetStart..resetEnd];
+ Assert.Contains("state.ReportTrafficSeen = false;", reset, StringComparison.Ordinal);
+ Assert.Contains("state.ReportChangeVerified = false;", reset, StringComparison.Ordinal);
+ Assert.Contains("state.AwaitingCommandReportEdge = false;", reset, StringComparison.Ordinal);
+ Assert.Contains("state.AcquisitionLabel = \"MMS polling\";", reset, StringComparison.Ordinal);
+ }
+
private static ReportControlPlan EnginePlan(
string planId,
string kind,
@@ -222,4 +302,17 @@ private static Iec61850MonitorDevice Device()
IpAddress = "192.0.2.10",
Port = 102
};
+
+ private static string FindRepoFile(string relativePath)
+ {
+ DirectoryInfo? directory = new(AppContext.BaseDirectory);
+ while (directory != null)
+ {
+ var candidate = Path.Combine(directory.FullName, relativePath);
+ if (File.Exists(candidate))
+ return candidate;
+ directory = directory.Parent;
+ }
+ throw new FileNotFoundException(relativePath);
+ }
}