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