Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .release/windows.json
Original file line number Diff line number Diff line change
@@ -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
}
6 changes: 3 additions & 3 deletions ArIED61850Tester.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
<Product>ARSAS</Product>
<AssemblyTitle>ARSAS - IEC 61850 Engineering Workstation</AssemblyTitle>
<Description>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.</Description>
<Version>1.6.30</Version>
<AssemblyVersion>1.6.30.0</AssemblyVersion>
<FileVersion>1.6.30.0</FileVersion>
<Version>1.6.31</Version>
<AssemblyVersion>1.6.31.0</AssemblyVersion>
<FileVersion>1.6.31.0</FileVersion>
<PackageProjectUrl>https://github.com/masarray/arsas</PackageProjectUrl>
<RepositoryUrl>https://github.com/masarray/arsas</RepositoryUrl>
<RepositoryType>git</RepositoryType>
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<Project>
<PropertyGroup>
<Version>1.6.30</Version>
<AssemblyVersion>1.6.30.0</AssemblyVersion>
<FileVersion>1.6.30.0</FileVersion>
<InformationalVersion>1.6.30</InformationalVersion>
<Version>1.6.31</Version>
<AssemblyVersion>1.6.31.0</AssemblyVersion>
<FileVersion>1.6.31.0</FileVersion>
<InformationalVersion>1.6.31</InformationalVersion>
</PropertyGroup>
</Project>
207 changes: 110 additions & 97 deletions Services/HybridReportPhysicalValidationTracker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,132 +28,145 @@ private sealed class PlanState

private readonly Dictionary<string, PlanState> _plans = new(StringComparer.OrdinalIgnoreCase);
private readonly List<string> _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(
ReportControlPlan plan,
NativeReportMonitorSliceResult slice,
IEnumerable<string>? 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)
Expand Down
64 changes: 53 additions & 11 deletions Services/Iec61850MonitorRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IReadOnlyList<ReportControlPlan>> BuildReportPlansForCurrentAssociationAsync(
DeviceSession session,
IReadOnlyList<ReportControlPlan> legacyPlans,
CancellationToken cancellationToken)
{
if (session.Client.CanUseHybridReportPlanner(session.Device))
{
NativeHybridReportPlanningResult hybrid;
Expand All @@ -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)
Expand Down Expand Up @@ -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<ReportControlPlan>();
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);
Comment on lines +1435 to +1438

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reset per-point report evidence before rebuilding plans

When a point had ReportChangeVerified set before the connection dropped, this reconnect path resets the hybrid tracker but leaves that flag and ReportTrafficSeen intact in session.States. The first GI or integrity update from the newly started plan is therefore included by ReceiveReportSlicesAsync in verifiedReportPointKeys, causing the fresh validation snapshot to claim a change-verified point even though no change has occurred on the new association. Clear the per-association report evidence flags before starting these rebuilt plans so reconnects cannot produce false physical-validation evidence.

Useful? React with 👍 / 👎.

await StartReportPlansAsync(session, plans, cancellationToken).ConfigureAwait(false);
ResetPollQueue(session);
UpdateDeviceAcquisitionSummary(session);
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.6.30
1.6.31
Loading
Loading