From b459f85874cbfb0be7c80efe4c9f223c12754e81 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 16:01:34 +0700 Subject: [PATCH 01/51] Static DataSet: select exact authoritative membership rows --- ...Iec61850StaticDataSetAuthoritySelection.cs | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 Services/Iec61850StaticDataSetAuthoritySelection.cs diff --git a/Services/Iec61850StaticDataSetAuthoritySelection.cs b/Services/Iec61850StaticDataSetAuthoritySelection.cs new file mode 100644 index 000000000..638440473 --- /dev/null +++ b/Services/Iec61850StaticDataSetAuthoritySelection.cs @@ -0,0 +1,87 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +/// +/// Builds the exact Static DataSet selection used by the report-only workflow. +/// +/// A DataSetReference on a browsed/runtime alias is not sufficient authority: several +/// aliases can point at the same static FCDA/FCD member (for example cVal/instCVal or +/// structured measurement descendants). Static mode must select one presentation row +/// for each engine-authoritative DataSet membership, preserving the literal member +/// identity that appears in SCL/ARIEC. +/// +public static class Iec61850StaticDataSetAuthoritySelection +{ + public static IReadOnlySet Build(Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(device); + + var model = device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel; + if (model is null) + return new HashSet(ReferenceEqualityComparer.Instance); + + var mandatory = Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(model); + var selected = new HashSet(ReferenceEqualityComparer.Instance); + var signals = device.Signals.ToArray(); + + foreach (var descriptor in mandatory) + { + var membership = descriptor.DataSetMemberships + .OrderBy(item => item.DataSetReference, StringComparer.OrdinalIgnoreCase) + .ThenBy(item => item.MemberIndex) + .FirstOrDefault(); + if (membership is null) + continue; + + var memberReference = FirstNonEmpty( + membership.CanonicalMemberReference, + membership.OriginalMemberReference, + descriptor.DesignReference, + descriptor.ObservedReference, + descriptor.PrimaryValueReference); + if (string.IsNullOrWhiteSpace(memberReference) || + string.IsNullOrWhiteSpace(membership.DataSetReference)) + continue; + + var candidates = signals + .Where(signal => !signal.IsControlSignal && signal.CanPublishToRuntime) + .Where(signal => LiteralEquals(signal.DataSetReference, membership.DataSetReference)) + .Where(signal => LiteralEquals(signal.DisplayReference, memberReference)) + .ToArray(); + if (candidates.Length == 0) + continue; + + // Prefer the runtime row already bound to ARIEC's resolved primary value. + // For an unresolved structured member, the inventory-created exact static row + // wins over a generic browsed alias. Never choose by fuzzy/prefix matching. + var chosen = candidates + .OrderByDescending(signal => + !string.IsNullOrWhiteSpace(descriptor.PrimaryValueReference) && + LiteralEquals(signal.ObjectReference, descriptor.PrimaryValueReference)) + .ThenByDescending(signal => + (signal.Source ?? string.Empty).Contains( + "mandatory static DataSet member", + StringComparison.OrdinalIgnoreCase)) + .ThenByDescending(signal => + string.Equals(signal.Category, "DataSet", StringComparison.OrdinalIgnoreCase)) + .ThenByDescending(signal => + string.Equals(signal.Confidence, "High", StringComparison.OrdinalIgnoreCase)) + .First(); + + selected.Add(chosen); + } + + return selected; + } + + private static bool LiteralEquals(string? left, string? right) + => string.Equals(NormalizeLiteral(left), NormalizeLiteral(right), StringComparison.OrdinalIgnoreCase); + + private static string NormalizeLiteral(string? value) + => (value ?? string.Empty).Trim(); + + private static string FirstNonEmpty(params string?[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; +} From 235e0b84b8112845e4f79a85e92b16f436af14dc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 16:01:54 +0700 Subject: [PATCH 02/51] Static DataSet: pin selection to exact ARIEC memberships --- MainWindow.SharedSclWorkspace.cs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/MainWindow.SharedSclWorkspace.cs b/MainWindow.SharedSclWorkspace.cs index 0b602b1b7..5f633c5b8 100644 --- a/MainWindow.SharedSclWorkspace.cs +++ b/MainWindow.SharedSclWorkspace.cs @@ -37,19 +37,20 @@ private enum SclSignalSelectionMode private void ApplyStaticDataSetSelection(Iec61850MonitorDevice device) { - // Static DataSet is a protocol-authority mode, not a request to monitor the - // whole IED and then opportunistically prefer reports. First make sure every - // ARIEC-owned DataSet member is present in the workspace, then select only - // runtime signals that carry an explicit static DataSet identity. + // Static DataSet remains the protocol authority established by the report-only + // baseline. Materialize every ARIEC-owned member first, then select exactly one + // presentation/runtime row per literal static membership. A browsed alias carrying + // DataSetReference is not enough authority and must not inflate the live plan. var merge = Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(device); RegisterRecoveredDataSetSignals(device, merge); + var authoritativeSignals = Iec61850StaticDataSetAuthoritySelection.Build(device); Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device); device.BeginBulkSignalSelection(); try { foreach (var signal in device.Signals) - signal.IsSelected = Iec61850StaticDataSetSelectionPolicy.IsEligible(signal); + signal.IsSelected = authoritativeSignals.Contains(signal); } finally { @@ -64,7 +65,7 @@ private void ApplyStaticDataSetSelection(Iec61850MonitorDevice device) AddLog( "INFO", device.Name, - $"Static DataSet report-only authority selected: {device.SelectedLiveSignalCount} runtime DataSet signal(s); cyclic MMS process polling and dynamic DataSet writes are disabled for this monitoring mode."); + $"Static DataSet report-only authority selected: {device.SelectedLiveSignalCount} exact runtime member row(s) from {merge.MandatoryCatalogCount} ARIEC static membership descriptor(s); cyclic MMS process polling and dynamic DataSet writes remain disabled."); } private void ClearSharedSignalSelection(Iec61850MonitorDevice device) From c637737747f62b69b72426956dbafd7f287d420c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 16:02:35 +0700 Subject: [PATCH 03/51] test: pin exact Static DataSet authority without MMS fallback --- ...ticDataSetReportOnlyModeRegressionTests.cs | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs index 7c01ed719..6827ca383 100644 --- a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs +++ b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs @@ -18,7 +18,7 @@ public void StaticMode_DisablesDynamicWrites_AndManualModeRestoresPriorValue() } [Fact] - public void StaticSelection_AllowsOnlyRuntimeSignalsWithExplicitDataSetAuthority() + public void StaticSelectionPolicy_StillRejectsBrowsedAndControlSignals() { var dataSetLeaf = Signal("IEDLD/MMXU1.TotW.mag.f", "IEDLD/LLN0.Analog"); var browsedLeaf = Signal("IEDLD/MMXU1.Hz.mag.f", string.Empty); @@ -40,13 +40,37 @@ public void RuntimeContract_StaticDataSetMode_DoesNotScheduleCyclicMmsProcessPol } [Fact] - public void SharedSclStaticSelection_UsesDatasetAuthorityPolicy_NotSelectEverything() + public void SharedSclStaticSelection_UsesExactAriecMembershipRows_NotEveryDatasetTaggedAlias() { var source = File.ReadAllText(FindRepoFile("MainWindow.SharedSclWorkspace.cs")); + var authority = File.ReadAllText(FindRepoFile("Services/Iec61850StaticDataSetAuthoritySelection.cs")); + Assert.Contains("Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(device)", source, StringComparison.Ordinal); + Assert.Contains("Iec61850StaticDataSetAuthoritySelection.Build(device)", source, StringComparison.Ordinal); + Assert.Contains("signal.IsSelected = authoritativeSignals.Contains(signal)", source, StringComparison.Ordinal); Assert.Contains("Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device)", source, StringComparison.Ordinal); - Assert.Contains("Iec61850StaticDataSetSelectionPolicy.IsEligible(signal)", source, StringComparison.Ordinal); - Assert.DoesNotContain("signal.IsSelected = !string.IsNullOrWhiteSpace(signal.DataSetReference)", source, StringComparison.Ordinal); + Assert.DoesNotContain("Iec61850StaticDataSetSelectionPolicy.IsEligible(signal)", source, StringComparison.Ordinal); + Assert.DoesNotContain("UseStaticDataSetWithMmsFallback", source, StringComparison.Ordinal); + Assert.DoesNotContain("fallback remains available", source, StringComparison.OrdinalIgnoreCase); + + Assert.Contains("Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(model)", authority, StringComparison.Ordinal); + Assert.Contains("LiteralEquals(signal.DataSetReference, membership.DataSetReference)", authority, StringComparison.Ordinal); + Assert.Contains("LiteralEquals(signal.DisplayReference, memberReference)", authority, StringComparison.Ordinal); + Assert.DoesNotContain("StartsWith(memberReference", authority, StringComparison.Ordinal); + Assert.DoesNotContain("Contains(memberReference", authority, StringComparison.Ordinal); + } + + [Fact] + public void StaticDataSetRegressionGuard_PreservesFa16ReportOnlyDirection() + { + var shared = File.ReadAllText(FindRepoFile("MainWindow.SharedSclWorkspace.cs")); + var registry = File.ReadAllText(FindRepoFile("Services/Iec61850MonitoringModeRegistry.cs")); + + Assert.Contains("UseStaticDataSetReportOnly(device)", shared, StringComparison.Ordinal); + Assert.Contains("Static DataSet report-only authority selected", shared, StringComparison.Ordinal); + Assert.Contains("cyclic MMS process polling and dynamic DataSet writes remain disabled", shared, StringComparison.Ordinal); + Assert.Contains("state.StaticDataSetReportOnly = true", registry, StringComparison.Ordinal); + Assert.DoesNotContain("UseStaticDataSetWithMmsFallback", registry, StringComparison.Ordinal); } private static SignalDefinition Signal(string reference, string dataSetReference) From 3c0e0c865182331084a98bf326bcf82da4e1bbce Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 16:32:38 +0700 Subject: [PATCH 04/51] Static DataSet: reconstruct structured report members without MMS --- ...taticDataSetReportProjectionAccumulator.cs | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 Services/StaticDataSetReportProjectionAccumulator.cs diff --git a/Services/StaticDataSetReportProjectionAccumulator.cs b/Services/StaticDataSetReportProjectionAccumulator.cs new file mode 100644 index 000000000..2ec4a2ca1 --- /dev/null +++ b/Services/StaticDataSetReportProjectionAccumulator.cs @@ -0,0 +1,251 @@ +using System.Globalization; +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +/// +/// Reconstructs a single Static DataSet member value from schema-proven report leaves. +/// +/// ARIEC deliberately expands structured report members such as MHAI.ThdA/ThdPPV into +/// exact scalar descendants. Static DataSet mode, however, presents one row per literal +/// FCDA/FCD membership (the same boundary an IED engineering browser shows). This +/// accumulator joins only the exact leaves named by the authoritative SCL/live schema and +/// publishes a synthetic parent update after every required leaf has been observed. +/// No MMS read, positional child guessing, prefix-only phase choice, or fuzzy DataSet +/// mapping is performed here. +/// +public sealed class StaticDataSetReportProjectionAccumulator +{ + private sealed record LeafState( + string Reference, + string Label, + string Value, + string Quality, + string Timestamp, + string Reason, + DateTimeOffset UpdatedAt, + NativeReportValueUpdate Source); + + private readonly Dictionary> _leavesByParent = + new(StringComparer.OrdinalIgnoreCase); + + public void Reset() => _leavesByParent.Clear(); + + /// + /// Returns the updates that should be consumed by the Static DataSet runtime for one + /// engine report update. Ordinary scalar members pass through unchanged. A semantic + /// descendant belonging to a selected structured parent is withheld until the parent + /// can be reconstructed from all exact schema-named leaves. If the descendant itself + /// is also an independently selected DataSet member, its original update is preserved. + /// + public IReadOnlyList Project( + LiveIedModelDiscoveryDocument? authorityModel, + IReadOnlyCollection monitoredPoints, + NativeReportValueUpdate update) + { + ArgumentNullException.ThrowIfNull(monitoredPoints); + ArgumentNullException.ThrowIfNull(update); + + if (!update.HasValue || string.IsNullOrWhiteSpace(update.Reference)) + return new[] { update }; + + var updateReference = NormalizeReference(update.Reference); + var exactSelected = monitoredPoints.Any(point => + ReferencesEqual(point.IecReference, update.Reference)); + + var aggregateParents = monitoredPoints + .Where(point => IsAggregate(point.IecReference)) + .Where(point => IsDescendant(updateReference, NormalizeReference(point.IecReference))) + .ToArray(); + + // A semantic child may not be assigned to an aggregate unless its parent is unique. + // In an ambiguous model, preserve only an independently selected exact child. + if (aggregateParents.Length != 1) + return exactSelected ? new[] { update } : aggregateParents.Length == 0 ? new[] { update } : Array.Empty(); + + var parent = aggregateParents[0]; + if (!SchemaSafeAggregateProjectionService.TryBuildReadPlan( + authorityModel, + parent.IecReference, + out var plan, + out _)) + { + return exactSelected ? new[] { update } : Array.Empty(); + } + + var expectedLeaf = plan.Leaves.FirstOrDefault(leaf => + ReferencesEqual(leaf.Reference, update.Reference)); + if (expectedLeaf is null) + { + // Ignore non-authoritative siblings such as instCVal when cVal is the schema- + // preferred engineering value. Keep it only when it is a separate selected row. + return exactSelected ? new[] { update } : Array.Empty(); + } + + var parentKey = NormalizeReference(parent.IecReference); + if (!_leavesByParent.TryGetValue(parentKey, out var leaves)) + { + leaves = new Dictionary(StringComparer.OrdinalIgnoreCase); + _leavesByParent[parentKey] = leaves; + } + + leaves[NormalizeReference(expectedLeaf.Reference)] = new LeafState( + expectedLeaf.Reference, + expectedLeaf.Label, + update.Value, + update.HasQuality ? update.Quality : string.Empty, + update.HasTimestamp ? update.Timestamp : string.Empty, + update.Reason, + update.UpdatedAt, + update); + + var projected = BuildParentUpdate(parent, plan, leaves); + if (projected is null) + return exactSelected ? new[] { update } : Array.Empty(); + + return exactSelected + ? new[] { update, projected } + : new[] { projected }; + } + + private static NativeReportValueUpdate? BuildParentUpdate( + Iec61850MonitorPoint parent, + SchemaSafeAggregateProjectionService.ReadPlan plan, + IReadOnlyDictionary leaves) + { + if (plan.Kind.Equals("ThreePhaseThd", StringComparison.OrdinalIgnoreCase)) + { + var ordered = new List(plan.Leaves.Count); + foreach (var expected in plan.Leaves) + { + if (!leaves.TryGetValue(NormalizeReference(expected.Reference), out var leaf) || + !TryParseNumeric(leaf.Value, out _)) + { + return null; + } + ordered.Add(leaf); + } + + var display = string.Join(", ", ordered.Select(leaf => + { + TryParseNumeric(leaf.Value, out var numeric); + return $"{leaf.Label}={numeric.ToString("0.######", CultureInfo.InvariantCulture)}"; + })); + + return Synthesize( + parent.IecReference, + display, + "schema-safe-report-three-phase-aggregate", + ordered); + } + + if (plan.Kind.Equals("DemandEnergy", StringComparison.OrdinalIgnoreCase)) + { + var expected = plan.Leaves.SingleOrDefault(); + if (expected is null || + !leaves.TryGetValue(NormalizeReference(expected.Reference), out var leaf) || + !TryParseNumeric(leaf.Value, out var numeric)) + { + return null; + } + + var display = numeric.ToString("0.######", CultureInfo.InvariantCulture); + return Synthesize( + parent.IecReference, + display, + "schema-safe-report-demand-energy-aggregate", + new[] { leaf }); + } + + return null; + } + + private static NativeReportValueUpdate Synthesize( + string parentReference, + string value, + string projectionStatus, + IReadOnlyList leaves) + { + var latest = leaves + .OrderByDescending(leaf => leaf.UpdatedAt) + .First(); + var quality = SharedUseful(leaves.Select(leaf => leaf.Quality)); + var timestamp = SharedUseful(leaves.Select(leaf => leaf.Timestamp)); + var reasons = leaves + .Select(leaf => leaf.Reason) + .Where(IsUseful) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return new NativeReportValueUpdate + { + Reference = parentReference, + FunctionalConstraint = latest.Source.FunctionalConstraint, + Value = value, + Quality = quality, + Timestamp = timestamp, + Reason = reasons.Length == 0 + ? "schema-safe static DataSet report aggregate" + : string.Join(",", reasons), + Source = "report", + ProjectionStatus = projectionStatus, + HasValue = true, + HasQuality = IsUseful(quality), + HasTimestamp = IsUseful(timestamp), + ReportControlReference = latest.Source.ReportControlReference, + ReportId = latest.Source.ReportId, + DataSetReference = latest.Source.DataSetReference, + SequenceNumber = latest.Source.SequenceNumber, + ConfRev = latest.Source.ConfRev, + ReportTimestamp = latest.Source.ReportTimestamp, + UpdatedAt = leaves.Max(leaf => leaf.UpdatedAt) + }; + } + + private static string SharedUseful(IEnumerable values) + { + var useful = values + .Where(IsUseful) + .Select(value => value.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + return useful.Length == 1 ? useful[0] : string.Empty; + } + + private static bool IsAggregate(string? reference) + => SignalDefinition.IsThreePhaseMeasurementAggregate(reference) || + SignalDefinition.IsDemandEnergyAggregate(reference); + + private static bool IsDescendant(string child, string parent) + => !string.IsNullOrWhiteSpace(child) && + !string.IsNullOrWhiteSpace(parent) && + child.Length > parent.Length && + child.StartsWith(parent + ".", StringComparison.OrdinalIgnoreCase); + + private static bool ReferencesEqual(string? left, string? right) + => NormalizeReference(left).Equals(NormalizeReference(right), StringComparison.OrdinalIgnoreCase); + + private static string NormalizeReference(string? reference) + => (reference ?? string.Empty) + .Trim() + .Replace('$', '.') + .Replace("..", ".") + .ToLowerInvariant(); + + private static bool TryParseNumeric(string? value, out double numeric) + { + var token = (value ?? string.Empty) + .Trim() + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault() ?? string.Empty; + return double.TryParse( + token, + NumberStyles.Float | NumberStyles.AllowThousands, + CultureInfo.InvariantCulture, + out numeric); + } + + private static bool IsUseful(string? value) + => !string.IsNullOrWhiteSpace(value) && value != "-"; +} From 0a0a4b92e57a78526dca191ec4d68209f2941128 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 16:33:01 +0700 Subject: [PATCH 05/51] Test schema-safe Static DataSet report aggregation --- ...DataSetReportProjectionAccumulatorTests.cs | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 tests/ARSAS.Tests/StaticDataSetReportProjectionAccumulatorTests.cs diff --git a/tests/ARSAS.Tests/StaticDataSetReportProjectionAccumulatorTests.cs b/tests/ARSAS.Tests/StaticDataSetReportProjectionAccumulatorTests.cs new file mode 100644 index 000000000..3ebdd0261 --- /dev/null +++ b/tests/ARSAS.Tests/StaticDataSetReportProjectionAccumulatorTests.cs @@ -0,0 +1,215 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class StaticDataSetReportProjectionAccumulatorTests +{ + [Fact] + public void ThdA_SemanticLeaves_ReconstructOneStaticParentValue() + { + const string parent = "IEDLD0/I_MHAI1.ThdA"; + var model = Model(DataObject( + parent, + "ThdA", + Attribute(parent + ".phsA.cVal.mag.f", "phsA.cVal.mag.f"), + Attribute(parent + ".phsB.cVal.mag.f", "phsB.cVal.mag.f"), + Attribute(parent + ".phsC.cVal.mag.f", "phsC.cVal.mag.f"))); + var point = Point(parent); + var accumulator = new StaticDataSetReportProjectionAccumulator(); + + Assert.Empty(accumulator.Project(model, new[] { point }, Update(parent + ".phsA.cVal.mag.f", "1.25"))); + Assert.Empty(accumulator.Project(model, new[] { point }, Update(parent + ".phsB.cVal.mag.f", "2.5"))); + + var projected = Assert.Single(accumulator.Project( + model, + new[] { point }, + Update(parent + ".phsC.cVal.mag.f", "3.75"))); + + Assert.Equal(parent, projected.Reference, ignoreCase: true); + Assert.Equal("A=1.25, B=2.5, C=3.75", projected.Value); + Assert.Equal("good", projected.Quality, ignoreCase: true); + Assert.Equal("2026-09-04 16:00:00.000", projected.Timestamp); + Assert.Equal("schema-safe-report-three-phase-aggregate", projected.ProjectionStatus); + Assert.True(projected.HasValue); + Assert.True(projected.HasQuality); + Assert.True(projected.HasTimestamp); + } + + [Fact] + public void ThdA_InstantaneousSibling_DoesNotOverrideCanonicalCValPlan() + { + const string parent = "IEDLD0/I_MHAI1.ThdA"; + var model = Model(DataObject( + parent, + "ThdA", + Attribute(parent + ".phsA.cVal.mag.f", "phsA.cVal.mag.f"), + Attribute(parent + ".phsA.instCVal.mag.f", "phsA.instCVal.mag.f"), + Attribute(parent + ".phsB.cVal.mag.f", "phsB.cVal.mag.f"), + Attribute(parent + ".phsC.cVal.mag.f", "phsC.cVal.mag.f"))); + var accumulator = new StaticDataSetReportProjectionAccumulator(); + + Assert.Empty(accumulator.Project( + model, + new[] { Point(parent) }, + Update(parent + ".phsA.instCVal.mag.f", "99"))); + } + + [Fact] + public void DmdWhMV_ExactMagnitude_ReconstructsParentWithoutMmsRead() + { + const string parent = "IEDLD0/XPRE_MMTR1.DmdWhMV"; + var model = Model(DataObject( + parent, + "DmdWhMV", + Attribute(parent + ".instMag.f", "instMag.f"), + Attribute(parent + ".mag.f", "mag.f"))); + var accumulator = new StaticDataSetReportProjectionAccumulator(); + + Assert.Empty(accumulator.Project( + model, + new[] { Point(parent) }, + Update(parent + ".instMag.f", "9.9"))); + + var projected = Assert.Single(accumulator.Project( + model, + new[] { Point(parent) }, + Update(parent + ".mag.f", "1.055"))); + + Assert.Equal(parent, projected.Reference, ignoreCase: true); + Assert.Equal("1.055", projected.Value); + Assert.Equal("schema-safe-report-demand-energy-aggregate", projected.ProjectionStatus); + } + + [Fact] + public void ExactSelectedChild_IsPreservedAlongsideCompletedParentAggregate() + { + const string parent = "IEDLD0/I_MHAI1.ThdA"; + const string child = "IEDLD0/I_MHAI1.ThdA.phsC.cVal.mag.f"; + var model = Model(DataObject( + parent, + "ThdA", + Attribute(parent + ".phsA.cVal.mag.f", "phsA.cVal.mag.f"), + Attribute(parent + ".phsB.cVal.mag.f", "phsB.cVal.mag.f"), + Attribute(child, "phsC.cVal.mag.f"))); + var parentPoint = Point(parent); + var childPoint = Point(child); + var accumulator = new StaticDataSetReportProjectionAccumulator(); + + accumulator.Project(model, new[] { parentPoint, childPoint }, Update(parent + ".phsA.cVal.mag.f", "1")); + accumulator.Project(model, new[] { parentPoint, childPoint }, Update(parent + ".phsB.cVal.mag.f", "2")); + var updates = accumulator.Project(model, new[] { parentPoint, childPoint }, Update(child, "3")); + + Assert.Equal(2, updates.Count); + Assert.Contains(updates, update => update.Reference.Equals(child, StringComparison.OrdinalIgnoreCase)); + Assert.Contains(updates, update => update.Reference.Equals(parent, StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void RuntimeSource_StaticMode_UsesSchemaSafeReportAuthority_AndNoMmsFallback() + { + var source = File.ReadAllText(FindRepoFile("Services/Iec61850MonitorRuntime.cs")); + + Assert.Contains("StaticDataSetReportProjectionAccumulator StaticReportProjection", source, StringComparison.Ordinal); + Assert.Contains("!session.StaticDataSetReportOnly && RequiresExactMmsValueAuthority", source, StringComparison.Ordinal); + Assert.Contains("HasSchemaProvenReportValueAuthority(point, update)", source, StringComparison.Ordinal); + Assert.Contains("Engine fallback candidates are diagnostic only and are not scheduled as MMS process polling", source, StringComparison.Ordinal); + Assert.DoesNotContain("structured report projection is not yet schema-proven; unsafe MMS fallback is disabled", source, StringComparison.Ordinal); + } + + private static NativeReportValueUpdate Update(string reference, string value) + => new() + { + Reference = reference, + FunctionalConstraint = "MX", + Value = value, + Quality = "good", + Timestamp = "2026-09-04 16:00:00.000", + Reason = "integrity", + ProjectionStatus = "semantic-structured-leaf", + HasValue = true, + HasQuality = true, + HasTimestamp = true, + DataSetReference = "IEDLD0/LLN0.Analog", + ReportControlReference = "IEDLD0/LLN0.RP.Unbuffer02", + UpdatedAt = new DateTimeOffset(2026, 9, 4, 16, 0, 0, TimeSpan.FromHours(7)) + }; + + private static Iec61850MonitorPoint Point(string reference) + => new() + { + DeviceId = "IED", + DeviceName = "IED", + SignalName = reference.Split('.').Last(), + IecReference = reference, + FunctionalConstraint = "MX", + IecDataType = "FLOAT32", + Category = "DataSet", + DataSetReference = "IEDLD0/LLN0.Analog" + }; + + private static LiveIedModelDiscoveryDocument Model(params LiveIedDataObjectModel[] objects) + => new() + { + Source = "Static report regression", + IedName = "IED", + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = "IEDLD0", + Inst = "LD0", + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "MHAI1", + LnClass = "MHAI", + LnInst = "1", + DataObjects = objects + } + ] + } + ] + }; + + private static LiveIedDataObjectModel DataObject( + string reference, + string name, + params LiveIedDataAttributeModel[] attributes) + => new() + { + Reference = reference, + Name = name, + InferredCdc = "MV", + Attributes = attributes + }; + + private static LiveIedDataAttributeModel Attribute(string reference, string path) + => new() + { + ObjectReference = reference, + AttributePath = path, + FunctionalConstraint = "MX", + MmsReference = reference.Replace('.', '$'), + SclBType = "FLOAT32", + MmsType = "floating-point", + Source = "SCL.DataTypeTemplates", + TypeSource = "SCL.DataTypeTemplates", + TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact + }; + + 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); + } +} From 2c9a750671228545780b760c440319f88784818b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 16:36:31 +0700 Subject: [PATCH 06/51] Stage reviewed Static DataSet runtime patch 1/5 --- .github/patches/static-report-runtime.part00 | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/patches/static-report-runtime.part00 diff --git a/.github/patches/static-report-runtime.part00 b/.github/patches/static-report-runtime.part00 new file mode 100644 index 000000000..b48b34b1b --- /dev/null +++ b/.github/patches/static-report-runtime.part00 @@ -0,0 +1 @@ +LS0tIGEvU2VydmljZXMvSWVjNjE4NTBNb25pdG9yUnVudGltZS5jcworKysgYi9TZXJ2aWNlcy9JZWM2MTg1ME1vbml0b3JSdW50aW1lLmNzCkBAIC04Myw2ICs4Myw3IEBACiAgICAgICAgIHB1YmxpYyBzdHJpbmcgSGVhbHRoUHJvYmVQb2ludEtleSB7IGdldDsgc2V0OyB9ID0gc3RyaW5nLkVtcHR5Ow0KICAgICAgICAgcHVibGljIGludCBDb250cm9sQ29tbWFuZEFjdGl2ZTsNCiAgICAgICAgIHB1YmxpYyBIeWJyaWRSZXBvcnRQaHlzaWNhbFZhbGlkYXRpb25UcmFja2VyIEh5YnJpZFZhbGlkYXRpb24geyBnZXQ7IH0gPSBuZXcoKTsNCisgICAgICAgIHB1YmxpYyBTdGF0aWNEYXRhU2V0UmVwb3J0UHJvamVjdGlvbkFjY3VtdWxhdG9yIFN0YXRpY1JlcG9ydFByb2plY3Rpb24geyBnZXQ7IH0gPSBuZXcoKTsNCiAgICAgICAgIHB1YmxpYyBib29sIFN0YXRpY0RhdGFTZXRSZXBvcnRPbmx5IHsgZ2V0OyBzZXQ7IH0NCiAgICAgfQ0KIA0KQEAgLTM5OSw2ICs0MDAsNyBAQAogICAgICAgICBzZXNzaW9uLkNvbnNlY3V0aXZlSGVhbHRoUHJvYmVGYWlsdXJlcyA9IDA7DQogICAgICAgICBzZXNzaW9uLkhlYWx0aFByb2JlUG9pbnRLZXkgPSBzdHJpbmcuRW1wdHk7DQogICAgICAgICBzZXNzaW9uLkh5YnJpZFZhbGlkYXRpb24uUmVzZXQobnVsbCk7DQorICAgICAgICBzZXNzaW9uLlN0YXRpY1JlcG9ydFByb2plY3Rpb24uUmVzZXQoKTsNCiAgICAgICAgIHNlc3Npb24uU3RhdGljRGF0YVNldFJlcG9ydE9ubHkgPSBzdGF0aWNEYXRhU2V0UmVwb3J0T25seTsNCiANCiAgICAgICAgIHZhciBzYWZlUG9sbE1zID0gTWF0aC5DbGFtcChwb2xsaW5nSW50ZXJ2YWxNcyA8PSAwID8gMTAwMCA6IHBvbGxpbmdJbnRlcnZhbE1zLCA1MCwgNjAwMDAwKTsNCkBAIC03NDMsMjIgKzc0NSwyMSBAQAogICAgICAgICAgICAgICAgIHNlc3Npb24uQWN0aXZlUmVwb3J0UGxhbk9yZGVyLkFkZChwbGFuKTsNCiANCiAgICAgICAgICAgICAgICAgdmFyIGNvdmVyZWRQb2ludHMgPSBSZXNvbHZlQ292ZXJlZFBvaW50cyhwbGFuLCByZXN1bHQuQ292ZXJlZFJlZmVyZW5jZXMpOw0KLSAgICAgICAgICAgICAgICB2YXIgYWNxdWlzaXRpb25MYWJlbCA9IHN0cmluZy5Jc051bGxPcldoaXRlU3BhY2UocmVzdWx0LkFjcXVpc2l0aW9uTGFiZWwpDQotICAgICAgICAgICAgICAgICAgICA/IEJ1aWxkUGxhbkFjcXVpc2l0aW9uTGFiZWwocGxhbiwgcmVzdWx0LlVzZWREeW5hbWljRGF0YVNldCkNCi0gICAgICAgICAgICAgICAgICAgIDogcmVzdWx0LkFjcXVpc2l0aW9uTGFiZWw7DQorICAgICAgICAgICAgICAgIHZhciBhY3F1aXNpdGlvbkxhYmVsID0gc2Vzc2lvbi5TdGF0aWNEYXRhU2V0UmVwb3J0T25seQ0KKyAgICAgICAgICAgICAgICAgICAgPyBCdWlsZFN0YXRpY0RhdGFTZXRBY3F1aXNpdGlvbkxhYmVsKHBsYW4pDQorICAgICAgICAgICAgICAgICAgICA6IHN0cmluZy5Jc051bGxPcldoaXRlU3BhY2UocmVzdWx0LkFjcXVpc2l0aW9uTGFiZWwpDQorICAgICAgICAgICAgICAgICAgICAgICAgPyBCdWlsZFBsYW5BY3F1aXNpdGlvbkxhYmVsKHBsYW4sIHJlc3VsdC5Vc2VkRHluYW1pY0RhdGFTZXQpDQorICAgICAgICAgICAgICAgICAgICAgICAgOiByZXN1bHQuQWNxdWlzaXRpb25MYWJlbDsNCiAgICAgICAgICAgICAgICAgZm9yZWFjaCAodmFyIHBvaW50IGluIGNvdmVyZWRQb2ludHMpDQogICAgICAgICAgICAgICAgIHsNCi0gICAgICAgICAgICAgICAgICAgIGlmIChSZXF1aXJlc0V4YWN0TW1zVmFsdWVBdXRob3JpdHkocG9pbnQuSWVjUmVmZXJlbmNlKSkNCi0gICAgICAgICAgICAgICAgICAgIHsNCi0gICAgICAgICAgICAgICAgICAgICAgICBpZiAoc2Vzc2lvbi5TdGF0aWNEYXRhU2V0UmVwb3J0T25seSAmJiBzZXNzaW9uLlN0YXRlcy5UcnlHZXRWYWx1ZShwb2ludC5Qb2ludEtleSwgb3V0IHZhciB1bnJlc29sdmVkU3RhdGUpKQ0KLSAgICAgICAgICAgICAgICAgICAgICAgIHsNCi0gICAgICAgICAgICAgICAgICAgICAgICAgICAgdW5yZXNvbHZlZFN0YXRlLlNvdXJjZU1vZGUgPSAiU3RhdGljIERhdGFTZXQ6IHJlcG9ydCBsZWFmIHVucmVzb2x2ZWQiOw0KLSAgICAgICAgICAgICAgICAgICAgICAgICAgICB1bnJlc29sdmVkU3RhdGUuQWNxdWlzaXRpb25MYWJlbCA9IHVucmVzb2x2ZWRTdGF0ZS5Tb3VyY2VNb2RlOw0KLSAgICAgICAgICAgICAgICAgICAgICAgICAgICB1bnJlc29sdmVkU3RhdGUuUmVhc29uID0gInN0cnVjdHVyZWQgcmVwb3J0IHByb2plY3Rpb24gaXMgbm90IHlldCBzY2hlbWEtcHJvdmVuOyB1bnNhZmUgTU1TIGZhbGxiYWNrIGlzIGRpc2FibGVkIGluIFN0YXRpYyBEYXRhU2V0IG1vZGUiOw0KLSAgICAgICAgICAgICAgICAgICAgICAgICAgICBFbWl0U3RhdHVzU25hcHNob3QocG9pbnQsIHVucmVzb2x2ZWRTdGF0ZSwgIlN0YXRpYyByZXBvcnQgbGVhZiB1bnJlc29sdmVkIC8gbm8gTU1TIGZhbGxiYWNrIiwgIlBlbmRpbmciKTsNCi0gICAgICAgICAgICAgICAgICAgICAgICB9DQorICAgICAgICAgICAgICAgICAgICAvLyBIeWJyaWQvbWFudWFsIG1vZGUgcmV0YWlucyBpdHMgY29uc2VydmF0aXZlIGV4YWN0LU1NUyBhdXRob3JpdHkgcnVsZS4NCisgICAgICAgICAgICAgICAgICAgIC8vIFN0YXRpYyBEYXRhU2V0IHJlcG9ydC1vbmx5IG1vZGUgaXMgZGlmZmVyZW50OiB3aGVuIEFSSUVDIHByb3ZlcyB0aGF0DQorICAgICAgICAgICAgICAgICAgICAvLyB0aGUgY29uZmlndXJlZCBSQ0IgY292ZXJzIHRoZSBsaXRlcmFsIERhdGFTZXQgbWVtYmVyLCBiaW5kIHRoYXQgcG9pbnQNCisgICAgICAgICAgICAgICAgICAgIC8vIHRvIHRoZSByZXBvcnQgcGxhbiBhbmQgbGV0IHNjaGVtYS1zYWZlIHJlcG9ydCBwcm9qZWN0aW9uIGRlY2lkZSB3aGV0aGVyDQorICAgICAgICAgICAgICAgICAgICAvLyBhbiBhcnJpdmluZyB2YWx1ZSBpcyBwdWJsaXNoYWJsZS4gTmV2ZXIgY29udmVydCB0aGlzIGNhc2UgaW50byBNTVMuDQorICAgICAgICAgICAgICAgICAgICBpZiAoIXNlc3Npb24uU3RhdGljRGF0YVNldFJlcG9ydE9ubHkgJiYgUmVxdWlyZXNFeGFjdE1tc1ZhbHVlQXV0aG9yaXR5KHBvaW50LkllY1JlZmVyZW5jZSkpDQogICAgICAgICAgICAgICAgICAgICAgICAgY29udGludWU7DQotICAgICAgICAgICAgICAgICAgICB9DQorDQogICAgICAgICAgICAgICAgICAgICBzZXNzaW9uLlBvaW50UGxhbklkc1twb2ludC5Qb2ludEtleV0gPSBwbGFuLlBsYW5JZDsNCiAgICAgICAgICAgICAgICAgICAgIGlmICghc3RyaW5nLklzTnVsbE9yV2hpdGVTcGFjZShyZXN1bHQuUmVwb3J0Q29udHJvbFJlZmVyZW5jZSkpDQogICAgICAgICAgICAgICAgICAgICAgICAgcG9pbnQuUmVwb3J0Q29udHJvbFJlZmVyZW5jZSA9IHJlc3VsdC5SZXBvcnRDb250cm9sUmVmZXJlbmNlOw0KQEAgLTc2OCw4ICs3NjksMTIgQEAKICAgICAgICAgICAgICAgICAgICAgew0KICAgICAgICAgICAgICAgICAgICAgICAgIHBvaW50U3RhdGUuQWNxdWlzaXRpb25MYWJlbCA9IGFjcXVpc2l0aW9uTGFiZWw7DQogICAgICAgICAgICAgICAgICAgICAgICAgcG9pbnRTdGF0ZS5Tb3VyY2VNb2RlID0gYWNxdWlzaXRpb25MYWJlbDsNCi0gICAgICAgICAgICAgICAgICAgICAgICBwb2ludFN0YXRlLlJlYXNvbiA9ICJhd2FpdGluZyByZXBvcnQgLyBNTVMgaW5pdGlhbCByZWFkIjsNCi0gICAgICAgICAgICAgICAgICAgICAgICBwb2ludFN0YXRlLlN0YXR1cyA9ICJSZXBvcnQgYXJtZWQgLyB2ZXJpZmljYXRpb24gYWN0aXZlIjsNCisg \ No newline at end of file From 939d4f1c913a184210ff20da3198f1d5950ee18c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 16:37:07 +0700 Subject: [PATCH 07/51] Stage reviewed Static DataSet runtime patch 2/5 --- .github/patches/static-report-runtime.part01 | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/patches/static-report-runtime.part01 diff --git a/.github/patches/static-report-runtime.part01 b/.github/patches/static-report-runtime.part01 new file mode 100644 index 000000000..7f775d36f --- /dev/null +++ b/.github/patches/static-report-runtime.part01 @@ -0,0 +1 @@ +ICAgICAgICAgICAgICAgICAgICAgICBwb2ludFN0YXRlLlJlYXNvbiA9IHNlc3Npb24uU3RhdGljRGF0YVNldFJlcG9ydE9ubHkNCisgICAgICAgICAgICAgICAgICAgICAgICAgICAgPyAiYXdhaXRpbmcgY29uZmlndXJlZCBzdGF0aWMgcmVwb3J0IC8gR2VuZXJhbCBJbnRlcnJvZ2F0aW9uIg0KKyAgICAgICAgICAgICAgICAgICAgICAgICAgICA6ICJhd2FpdGluZyByZXBvcnQgLyBNTVMgaW5pdGlhbCByZWFkIjsNCisgICAgICAgICAgICAgICAgICAgICAgICBwb2ludFN0YXRlLlN0YXR1cyA9IHNlc3Npb24uU3RhdGljRGF0YVNldFJlcG9ydE9ubHkNCisgICAgICAgICAgICAgICAgICAgICAgICAgICAgPyAiU3RhdGljIHJlcG9ydCBhcm1lZCAvIGF3YWl0aW5nIHZhbHVlIg0KKyAgICAgICAgICAgICAgICAgICAgICAgICAgICA6ICJSZXBvcnQgYXJtZWQgLyB2ZXJpZmljYXRpb24gYWN0aXZlIjsNCiAgICAgICAgICAgICAgICAgICAgIH0NCiAgICAgICAgICAgICAgICAgfQ0KIA0KQEAgLTk1NCwyMiArOTU5LDM3IEBACiAgICAgICAgICAgICAgICAgew0KICAgICAgICAgICAgICAgICAgICAgSXNBdXRob3JpdGF0aXZlID0gdHJ1ZSwNCiAgICAgICAgICAgICAgICAgICAgIEF1dGhvcml0eSA9ICJBUklFQzYxODUwIGh5YnJpZCBhY3F1aXNpdGlvbiIsDQotICAgICAgICAgICAgICAgICAgICBTdGF0dXMgPSAiUGxhbm5lciBmYWlsdXJlIC8gcG9sbGluZyBzYWZlIiwNCi0gICAgICAgICAgICAgICAgICAgIFN1bW1hcnkgPSAkIkFSSUVDIGh5YnJpZCBwbGFubmluZyBmYWlsZWQgY2xvc2VkOiB7ZXguR2V0VHlwZSgpLk5hbWV9OiB7ZXguTWVzc2FnZX0uIE5vIGxvY2FsIFJDQiBoZXVyaXN0aWMgd2FzIHN1YnN0aXR1dGVkOyBib3VuZGVkIE1NUyBwb2xsaW5nIHJlbWFpbnMgYWN0aXZlLiIsDQorICAgICAgICAgICAgICAgICAgICBTdGF0dXMgPSBzZXNzaW9uLlN0YXRpY0RhdGFTZXRSZXBvcnRPbmx5DQorICAgICAgICAgICAgICAgICAgICAgICAgPyAiUGxhbm5lciBmYWlsdXJlIC8gc3RhdGljIHJlcG9ydCB1bmF2YWlsYWJsZSINCisgICAgICAgICAgICAgICAgICAgICAgICA6ICJQbGFubmVyIGZhaWx1cmUgLyBwb2xsaW5nIHNhZmUiLA0KKyAgICAgICAgICAgICAgICAgICAgU3VtbWFyeSA9IHNlc3Npb24uU3RhdGljRGF0YVNldFJlcG9ydE9ubHkNCisgICAgICAgICAgICAgICAgICAgICAgICA/ICQiQVJJRUMgcmVwb3J0IHBsYW5uaW5nIGZhaWxlZCBjbG9zZWQ6IHtleC5HZXRUeXBlKCkuTmFtZX06IHtleC5NZXNzYWdlfS4gTm8gbG9jYWwgUkNCIGhldXJpc3RpYyB3YXMgc3Vic3RpdHV0ZWQgYW5kIE1NUyBwcm9jZXNzIGZhbGxiYWNrIHJlbWFpbnMgZGlzYWJsZWQgYnkgU3RhdGljIERhdGFTZXQgbW9kZS4iDQorICAgICAgICAgICAgICAgICAgICAgICAgOiAkIkFSSUVDIGh5YnJpZCBwbGFubmluZyBmYWlsZWQgY2xvc2VkOiB7ZXguR2V0VHlwZSgpLk5hbWV9OiB7ZXguTWVzc2FnZX0uIE5vIGxvY2FsIFJDQiBoZXVyaXN0aWMgd2FzIHN1YnN0aXR1dGVkOyBib3VuZGVkIE1NUyBwb2xsaW5nIHJlbWFpbnMgYWN0aXZlLiIsDQogICAgICAgICAgICAgICAgICAgICBSZXF1ZXN0ZWRQb2ludENvdW50ID0gc2Vzc2lvbi5Qb2ludHMuQ291bnQsDQotICAgICAgICAgICAgICAgICAgICBQb2xsaW5nUG9pbnRLZXlzID0gc2Vzc2lvbi5Qb2ludHMuS2V5cy5Ub0FycmF5KCksDQotICAgICAgICAgICAgICAgICAgICBQb2xsaW5nRmFsbGJhY2tTaWduYWxDb3VudCA9IHNlc3Npb24uUG9pbnRzLkNvdW50LA0KKyAgICAgICAgICAgICAgICAgICAgUG9sbGluZ1BvaW50S2V5cyA9IHNlc3Npb24uU3RhdGljRGF0YVNldFJlcG9ydE9ubHkgPyBBcnJheS5FbXB0eTxzdHJpbmc+KCkgOiBzZXNzaW9uLlBvaW50cy5LZXlzLlRvQXJyYXkoKSwNCisgICAgICAgICAgICAgICAgICAgIFBvbGxpbmdGYWxsYmFja1NpZ25hbENvdW50ID0gc2Vzc2lvbi5TdGF0aWNEYXRhU2V0UmVwb3J0T25seSA/IDAgOiBzZXNzaW9uLlBvaW50cy5Db3VudCwNCisgICAgICAgICAgICAgICAgICAgIFVuY292ZXJlZFNpZ25hbENvdW50ID0gc2Vzc2lvbi5TdGF0aWNEYXRhU2V0UmVwb3J0T25seSA/IHNlc3Npb24uUG9pbnRzLkNvdW50IDogMCwNCiAgICAgICAgICAgICAgICAgICAgIFdhcm5pbmdzID0gWyQiSHlicmlkIHBsYW5uaW5nIGV4Y2VwdGlvbjoge2V4LkdldFR5cGUoKS5OYW1lfToge2V4Lk1lc3NhZ2V9Il0NCiAgICAgICAgICAgICAgICAgfTsNCiAgICAgICAgICAgICB9DQogDQogICAgICAgICAgICAgc2Vzc2lvbi5IeWJyaWRWYWxpZGF0aW9uLlJlc2V0KGh5YnJpZCk7DQotICAgICAgICAgICAgTG9nKCJJTkZPIiwgc2Vzc2lvbi5EZXZpY2UuTmFtZSwNCi0gICAgICAgICAgICAgICAgJCJIeWJyaWQgYXV0aG9yaXR5PXtoeWJyaWQuQXV0aG9yaXR5fTsgc3RhdHVzPXtoeWJyaWQuU3RhdHVzfTsgcmVxdWVzdGVkPXtoeWJyaWQuUmVxdWVzdGVkUG9pbnRDb3VudH0sIGNhdGFsb2c9e2h5YnJpZC5DYXRhbG9nTWFwcGVkUG9pbnRDb3VudH0sIHN0YXRpY0JSQ0I9e2h5YnJpZC5TdGF0aWNCcmNiU2lnbmFsQ291bnR9LCBzdGF0aWNVUkNCPXtoeWJyaWQuU3RhdGljVXJjYlNpZ25hbENvdW50fSwgZHluYW1pY0JSQ0I9e2h5YnJpZC5EeW5hbWljQnJjYlNpZ25hbENvdW50fSwgZHluYW1pY1VSQ0I9e2h5YnJpZC5EeW5hbWljVXJjYlNpZ25hbENvdW50fSwgcG9sbGluZz17aHlicmlkLlBvbGxpbmdGYWxsYmFja1NpZ25hbENvdW50fSwgdW5jb3ZlcmVkPXtoeWJyaWQuVW5jb3ZlcmVkU2lnbmFsQ291bnR9LiB7aHlicmlkLlN1bW1hcnl9Iik7DQorICAgICAgICAgICAgaWYgKHNlc3Npb24uU3RhdGljRGF0YVNldFJlcG9ydE9ubHkpDQorICAgICAgICAgICAgew0KKyAgICAgICAgICAgICAgICBMb2coIklORk8iLCBzZXNzaW9uLkRldmljZS5OYW1lLA0KKyAgICAgICAgICAgICAgICAgICAgJCJTdGF0aWMgRGF0YVNldCBBUklFQyBhdXRob3JpdHk9e2h5YnJpZC5BdXRob3JpdHl9OyBzdGF0dXM9e2h5YnJpZC5TdGF0dXN9OyByZXF1ZXN0ZWQ9e2h5YnJpZC5SZXF1ZXN0ZWRQb2ludENvdW50fSwgY2F0YWxvZz17aHlicmlkLkNhdGFsb2dNYXBwZWRQb2ludENvdW50fSwgc3RhdGljQlJDQj17aHlicmlkLlN0YXRpY0JyY2JTaWduYWxDb3VudH0sIHN0YXRpY1VSQ0I9e2h5YnJpZC5TdGF0aWNVcmNiU2lnbmFsQ291bnR9LCBlbmdpbmVGYWxsYmFja0NhbmRpZGF0ZXM9e2h5YnJpZC5Qb2xsaW5nRmFsbGJhY2tTaWduYWxDb3VudH0sIHVuY292ZXJlZD17aHlicmlkLlVuY292ZXJlZFNpZ25hbENvdW50fS4gRW5naW5lIGZhbGxiYWNrIGNhbmRpZGF0ZXMgYXJlIGRpYWdub3N0aWMgb25seSBhbmQgYXJlIG5vdCBzY2hlZHVsZWQgYXMgTU1TIHByb2Nlc3MgcG9sbGluZy4iKTsNCisgICAgICAgICAgICB9DQorICAgICAgICAgICAgZWxzZQ0KKyAgICAgICAgICAgIHsNCisgICAgICAgICAgICAgICAgTG9nKCJJTkZPIiwgc2Vzc2lvbi5EZXZpY2UuTmFtZSwNCisgICAgICAgICAgICAgICAgICAgICQiSHlicmlkIGF1dGhvcml0eT17aHlicmlkLkF1dGhvcml0eX07IHN0YXR1cz17aHlicmlkLlN0YXR1c307IHJlcXVlc3RlZD17aHlicmlkLlJlcXVlc3RlZFBvaW50Q291bnR9LCBjYXRhbG9nPXtoeWJyaWQuQ2F0YWxvZ01hcHBlZFBvaW50Q291bnR9LCBzdGF0aWNCUkNCPXtoeWJyaWQuU3RhdGljQnJjYlNpZ25hbENvdW50fSwgc3RhdGljVVJDQj17aHlicmlkLlN0YXRpY1VyY2JTaWduYWxDb3VudH0sIGR5bmFtaWNCUkNC \ No newline at end of file From f2b357d599c3e22e21bd7aa6fca1763749eca99b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 16:37:45 +0700 Subject: [PATCH 08/51] Stage reviewed Static DataSet runtime patch 3/5 --- .github/patches/static-report-runtime.part02 | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/patches/static-report-runtime.part02 diff --git a/.github/patches/static-report-runtime.part02 b/.github/patches/static-report-runtime.part02 new file mode 100644 index 000000000..c423630dc --- /dev/null +++ b/.github/patches/static-report-runtime.part02 @@ -0,0 +1 @@ +PXtoeWJyaWQuRHluYW1pY0JyY2JTaWduYWxDb3VudH0sIGR5bmFtaWNVUkNCPXtoeWJyaWQuRHluYW1pY1VyY2JTaWduYWxDb3VudH0sIHBvbGxpbmc9e2h5YnJpZC5Qb2xsaW5nRmFsbGJhY2tTaWduYWxDb3VudH0sIHVuY292ZXJlZD17aHlicmlkLlVuY292ZXJlZFNpZ25hbENvdW50fS4ge2h5YnJpZC5TdW1tYXJ5fSIpOw0KKyAgICAgICAgICAgIH0NCiAgICAgICAgICAgICBmb3JlYWNoICh2YXIgd2FybmluZyBpbiBoeWJyaWQuV2FybmluZ3MuVGFrZSg1KSkNCiAgICAgICAgICAgICAgICAgTG9nKCJXQVJOIiwgc2Vzc2lvbi5EZXZpY2UuTmFtZSwgd2FybmluZyk7DQogDQotICAgICAgICAgICAgcmV0dXJuIGh5YnJpZC5SZXBvcnRQbGFuczsNCisgICAgICAgICAgICByZXR1cm4gc2Vzc2lvbi5TdGF0aWNEYXRhU2V0UmVwb3J0T25seQ0KKyAgICAgICAgICAgICAgICA/IGh5YnJpZC5SZXBvcnRQbGFucy5XaGVyZShwbGFuID0+ICFwbGFuLkFsbG93RHluYW1pY0RhdGFTZXRXcml0ZXMpLlRvQXJyYXkoKQ0KKyAgICAgICAgICAgICAgICA6IGh5YnJpZC5SZXBvcnRQbGFuczsNCiAgICAgICAgIH0NCiANCiAgICAgICAgIHNlc3Npb24uSHlicmlkVmFsaWRhdGlvbi5SZXNldChudWxsKTsNCkBAIC0xMDUxLDIyICsxMDcxLDM3IEBACiAgICAgICAgICAgICBpZiAoc2xpY2UuUmVwb3J0RnJhbWVzLkNvdW50ID4gMCB8fCBzbGljZS5VcGRhdGVzLkNvdW50ID4gMCkNCiAgICAgICAgICAgICAgICAgUmVjb3JkU3VjY2Vzc2Z1bElvKHNlc3Npb24pOw0KIA0KLSAgICAgICAgICAgIGZvcmVhY2ggKHZhciB1cGRhdGUgaW4gc2xpY2UuVXBkYXRlcykNCisgICAgICAgICAgICBmb3JlYWNoICh2YXIgc291cmNlVXBkYXRlIGluIHNsaWNlLlVwZGF0ZXMpDQogICAgICAgICAgICAgew0KLSAgICAgICAgICAgICAgICB2YXIgcG9pbnQgPSBGaW5kUG9pbnRGb3JSZXBvcnRSZWZlcmVuY2Uoc2Vzc2lvbiwgdXBkYXRlLlJlZmVyZW5jZSk7DQotICAgICAgICAgICAgICAgIGlmIChwb2ludCA9PSBudWxsKQ0KLSAgICAgICAgICAgICAgICAgICAgY29udGludWU7DQorICAgICAgICAgICAgICAgIHZhciBlZmZlY3RpdmVVcGRhdGVzID0gc2Vzc2lvbi5TdGF0aWNEYXRhU2V0UmVwb3J0T25seQ0KKyAgICAgICAgICAgICAgICAgICAgPyBzZXNzaW9uLlN0YXRpY1JlcG9ydFByb2plY3Rpb24uUHJvamVjdCgNCisgICAgICAgICAgICAgICAgICAgICAgICBzZXNzaW9uLkRldmljZS5MaXZlRGlzY292ZXJ5TW9kZWwgPz8gc2Vzc2lvbi5EZXZpY2UuU2NsV29ya3NwYWNlPy5EZXNpZ25Nb2RlbCwNCisgICAgICAgICAgICAgICAgICAgICAgICBzZXNzaW9uLlBvaW50cy5WYWx1ZXMuVG9BcnJheSgpLA0KKyAgICAgICAgICAgICAgICAgICAgICAgIHNvdXJjZVVwZGF0ZSkNCisgICAgICAgICAgICAgICAgICAgIDogbmV3W10geyBzb3VyY2VVcGRhdGUgfTsNCiANCi0gICAgICAgICAgICAgICAgLy8gU2llbWVucyBzdGF0aWMgRGF0YVNldHMgcmVwb3J0IFRIRCBhcyBhIG5lc3RlZCB0aHJlZS1waGFzZSBzdHJ1Y3R1cmUuDQotICAgICAgICAgICAgICAgIC8vIFVudGlsIGl0cyBzY2FsYXIgZmFuLW91dCBpcyBzY2hlbWEtcHJvdmVuLCBleGFjdCBNTVMgcmVhZHMgcmVtYWluIHRoZQ0KLSAgICAgICAgICAgICAgICAvLyB2YWx1ZSBhdXRob3JpdHkgc28gYSByZXBvcnQgcHJvamVjdGlvbiBjYW5ub3Qgb3ZlcndyaXRlIGNvcnJlY3QgdmFsdWVzLg0KLSAgICAgICAgICAgICAgICBpZiAoUmVxdWlyZXNFeGFjdE1tc1ZhbHVlQXV0aG9yaXR5KHBvaW50LkllY1JlZmVyZW5jZSkpDQotICAgICAgICAgICAgICAgICAgICBjb250aW51ZTsNCisgICAgICAgICAgICAgICAgZm9yZWFjaCAodmFyIHVwZGF0ZSBpbiBlZmZlY3RpdmVVcGRhdGVzKQ0KKyAgICAgICAgICAgICAgICB7DQorICAgICAgICAgICAgICAgICAgICB2YXIgcG9pbnQgPSBGaW5kUG9pbnRGb3JSZXBvcnRSZWZlcmVuY2Uoc2Vzc2lvbiwgdXBkYXRlLlJlZmVyZW5jZSk7DQorICAgICAgICAgICAgICAgICAgICBpZiAocG9pbnQgPT0gbnVsbCkNCisgICAgICAgICAgICAgICAgICAgICAgICBjb250aW51ZTsNCisNCisgICAgICAgICAgICAgICAgICAgIC8vIFRoZSBvbGQgcnVsZSByZWplY3RlZCBldmVyeSBUSEQvRG1kV2ggcmVwb3J0IHZhbHVlIG1lcmVseSBiZWNhdXNlDQorICAgICAgICAgICAgICAgICAgICAvLyB0aG9zZSBmYW1pbGllcyBvbmNlIHJlcXVpcmVkIE1NUyByZWFkcy4gQVJJRUMgbm93IHByb3ZpZGVzIGV4YWN0DQorICAgICAgICAgICAgICAgICAgICAvLyBzZW1hbnRpYyBsZWFmIGlkZW50aXRpZXMsIGFuZCBTdGF0aWNEYXRhU2V0UmVwb3J0UHJvamVjdGlvbkFjY3VtdWxhdG9yDQorICAgICAgICAgICAgICAgICAgICAvLyByZWNvbnN0cnVjdHMgc3RydWN0dXJlZCBwYXJlbnRzIGZyb20gc2NoZW1hLW5hbWVkIGxlYXZlcy4gS2VlcCB0aGUNCisgICAgICAgICAgICAgICAgICAgIC8vIGxlZ2FjeSB2ZXRvIG9ubHkgd2hlbiB0aGUgcmVwb3J0IHVwZGF0ZSBpdHNlbGYgaXMgbm90IHNjaGVtYS1wcm92ZW4uDQorICAgICAgICAgICAgICAgICAgICBpZiAoUmVxdWlyZXNFeGFjdE1tc1ZhbHVlQXV0aG9yaXR5KHBvaW50LkllY1JlZmVyZW5jZSkgJiYNCisgICAgICAgICAgICAgICAgICAgICAgICAhSGFzU2NoZW1hUHJvdmVuUmVwb3J0VmFsdWVBdXRob3JpdHkocG9pbnQsIHVwZGF0ZSkpDQorICAgICAgICAgICAgICAgICAgICB7DQorICAgICAgICAgICAgICAgICAgICAgICAgY29udGludWU7DQorICAgICAgICAgICAgICAgICAgICB9DQogDQotICAgICAgICAgICAgICAgIC8vIEEgcmVhbCByZXBvcnQgdXBkYXRlIHByb3ZlcyByZWZlcmVuY2UgY292ZXJhZ2UsIGJ1dCBhbiBpbml0aWFsIEdJIG9yDQotICAgICAgICAgICAgICAgIC8vIGludGVncml0eSBpbWFnZSBkb2VzIG5vdCB5ZXQgcHJvdmUgdGhhdCBkY2hnL3FjaGcgaXMgd29ya2luZy4gS2VlcCBNTVMNCi0gICAgICAgICAgICAgICAgLy8gdmVyaWZpY2F0aW9uIGFjdGl2ZSB1bnRpbCBhIGNoYW5nZSByZXBvcnQgaXMgYWN0dWFsbHkgb2JzZXJ2ZWQuDQotICAgICAgICAgICAgICAgIHNlc3Npb24uUG9pbnRQbGFuSWRzW3BvaW50LlBvaW50S2V5XSA9IHBsYW4uUGxhbklkOw0KKyAgICAgICAgICAgICAgICAgICAgLy8gQSByZWFsIHJlcG9ydCB1cGRhdGUgcHJvdmVzIHJlZmVyZW5jZSBjb3ZlcmFnZS4gSW4gSHlicmlkIG1vZGUgYW4NCisgICAgICAgICAgICAgICAgICAgIC8vIGluaXRpYWwgR0kvaW50ZWdyaXR5IGltYWdlIHN0aWxsIGtlZXBzIE1NUyB2ZXJpZmljYXRpb24gYWN0aXZlIHVudGlsDQorICAgICAgICAgICAgICAgICAgICAvLyBkY2hnL3FjaGcgaXMgcHJvdmVuOyBTdGF0aWMgRGF0YVNldCBtb2RlIHB1Ymxpc2hlcyB0aGUgc2NoZW1hLXNhZmUgR0kNCisgICAgICAgICAgICAgICAgICAgIC8vIGltYWdlIGRpcmVjdGx5IGJlY2F1c2UgY3ljbGljIE1NUyBwcm9jZXNzIHZlcmlmaWNhdGlvbiBpcyBkaXNhYmxlZC4NCisgICAgICAgICAgICAgICAgICAgIHNlc3Npb24uUG9pbnRQbGFuSWRzW3BvaW50LlBvaW50S2V5XSA9IHBsYW4uUGxhbklkOw0KIA0KICAgICAgICAgICAgICAgICB2YXIgc3RhdGUgPSBzZXNzaW9uLlN0YXRlc1twb2ludC5Qb2ludEtleV07DQogICAgICAgICAgICAgICAgIHZhciBkaXNwbGF5ID0gdXBkYXRlLkhhc1ZhbHVlDQpAQCAtMTA4NCw3ICsxMTE5LDggQEAKICAgICAgICAgICAgICAgICB7DQogICAgICAgICAgICAgICAgICAgICAvLyBBIG1h \ No newline at end of file From f2a35aff4cff1be9510abf387700f712145db432 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 16:38:19 +0700 Subject: [PATCH 09/51] Stage reviewed Static DataSet runtime patch 4/5 --- .github/patches/static-report-runtime.part03 | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/patches/static-report-runtime.part03 diff --git a/.github/patches/static-report-runtime.part03 b/.github/patches/static-report-runtime.part03 new file mode 100644 index 000000000..0d99c256f --- /dev/null +++ b/.github/patches/static-report-runtime.part03 @@ -0,0 +1 @@ +bGZvcm1lZC9taXNhbGlnbmVkIHJlcG9ydCBpcyBzdGlsbCB1c2VmdWwgYXMgcHJvb2YgdGhhdCB0aGUNCiAgICAgICAgICAgICAgICAgICAgIC8vIFJDQiBpcyBhbGl2ZSwgYnV0IGl0cyBwcm9jZXNzIHZhbHVlIGlzIG5vdCBhdXRob3JpdGF0aXZlLiBEbyBub3QNCi0gICAgICAgICAgICAgICAgICAgIC8vIG11dGF0ZSBzdGF0ZSBvciBTT0UgaGlzdG9yeTsga2VlcCBNTVMgdmVyaWZpY2F0aW9uL2ZhbGxiYWNrIGFjdGl2ZS4NCisgICAgICAgICAgICAgICAgICAgIC8vIG11dGF0ZSBzdGF0ZSBvciBTT0UgaGlzdG9yeS4gSHlicmlkIG1vZGUga2VlcHMgTU1TIHZlcmlmaWNhdGlvbjsNCisgICAgICAgICAgICAgICAgICAgIC8vIFN0YXRpYyBEYXRhU2V0IG1vZGUgc3RheXMgcmVwb3J0LXBlbmRpbmcgd2l0aG91dCBhbnkgTU1TIGZhbGxiYWNrLg0KICAgICAgICAgICAgICAgICAgICAgc3RhdGUuUmVwb3J0VHJhZmZpY1NlZW4gPSB0cnVlOw0KICAgICAgICAgICAgICAgICAgICAgc3RhdGUuTGFzdFJlcG9ydFV0YyA9IERhdGVUaW1lLlV0Y05vdzsNCiAgICAgICAgICAgICAgICAgICAgIHN0YXRlLlJlcG9ydENoYW5nZVZlcmlmaWVkID0gZmFsc2U7DQpAQCAtMTA5Niw3ICsxMTMyLDkgQEAKICAgICAgICAgICAgICAgICAgICAgICAgIGlmIChyYXdTdW1tYXJ5Lkxlbmd0aCA+IDEyMCkNCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgcmF3U3VtbWFyeSA9IHJhd1N1bW1hcnlbLi4xMjBdICsgIuKApiI7DQogICAgICAgICAgICAgICAgICAgICAgICAgTG9nKCJXQVJOIiwgc2Vzc2lvbi5EZXZpY2UuTmFtZSwNCi0gICAgICAgICAgICAgICAgICAgICAgICAgICAgJCJSRVBPUlRfVkFMVUVfUkVKRUNURUQ6IHtwb2ludC5TaWduYWxOYW1lfSAoe3BvaW50LkllY1JlZmVyZW5jZX0pIHJlamVjdGVkIHJlcG9ydCB2YWx1ZSAne3Jhd1N1bW1hcnl9Jy4ge3JlamVjdGlvblJlYXNvbn0gTU1TIHZlcmlmaWNhdGlvbi9mYWxsYmFjayByZW1haW5zIGF1dGhvcml0YXRpdmUuIik7DQorICAgICAgICAgICAgICAgICAgICAgICAgICAgIHNlc3Npb24uU3RhdGljRGF0YVNldFJlcG9ydE9ubHkNCisgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgID8gJCJSRVBPUlRfVkFMVUVfUkVKRUNURUQ6IHtwb2ludC5TaWduYWxOYW1lfSAoe3BvaW50LkllY1JlZmVyZW5jZX0pIHJlamVjdGVkIHJlcG9ydCB2YWx1ZSAne3Jhd1N1bW1hcnl9Jy4ge3JlamVjdGlvblJlYXNvbn0gVGhlIHBvaW50IHJlbWFpbnMgcmVwb3J0LXBlbmRpbmc7IE1NUyBwcm9jZXNzIGZhbGxiYWNrIGlzIGRpc2FibGVkLiINCisgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDogJCJSRVBPUlRfVkFMVUVfUkVKRUNURUQ6IHtwb2ludC5TaWduYWxOYW1lfSAoe3BvaW50LkllY1JlZmVyZW5jZX0pIHJlamVjdGVkIHJlcG9ydCB2YWx1ZSAne3Jhd1N1bW1hcnl9Jy4ge3JlamVjdGlvblJlYXNvbn0gTU1TIHZlcmlmaWNhdGlvbi9mYWxsYmFjayByZW1haW5zIGF1dGhvcml0YXRpdmUuIik7DQogICAgICAgICAgICAgICAgICAgICB9DQogICAgICAgICAgICAgICAgICAgICBjb250aW51ZTsNCiAgICAgICAgICAgICAgICAgfQ0KQEAgLTExNjgsMTEgKzEyMDYsMTYgQEAKICAgICAgICAgICAgICAgICAgICAgcmVwb3J0U291cmNlLA0KICAgICAgICAgICAgICAgICAgICAgc3RyaW5nLklzTnVsbE9yV2hpdGVTcGFjZSh1cGRhdGUuUmVhc29uKSA/ICJyZXBvcnQgLyByZWFzb24gbm90IHN1cHBsaWVkIiA6IHVwZGF0ZS5SZWFzb24sDQogICAgICAgICAgICAgICAgICAgICByZWNlaXZlZFV0YywNCi0gICAgICAgICAgICAgICAgICAgIHN0YXRlLlJlcG9ydENoYW5nZVZlcmlmaWVkDQotICAgICAgICAgICAgICAgICAgICAgICAgPyBzdHJpbmcuSXNOdWxsT3JXaGl0ZVNwYWNlKHVwZGF0ZS5Qcm9qZWN0aW9uU3RhdHVzKSA/ICJMaXZlIC8gcmVwb3J0IHZlcmlmaWVkIiA6ICQiTGl2ZSAvIHJlcG9ydCB2ZXJpZmllZCAoe3VwZGF0ZS5Qcm9qZWN0aW9uU3RhdHVzfSkiDQotICAgICAgICAgICAgICAgICAgICAgICAgOiBzdHJpbmcuSXNOdWxsT3JXaGl0ZVNwYWNlKHVwZGF0ZS5Qcm9qZWN0aW9uU3RhdHVzKSA/ICJMaXZlIC8gcmVwb3J0IHRyYWZmaWMgKyBNTVMgdmVyaWZpY2F0aW9uIiA6ICQiTGl2ZSAvIHJlcG9ydCB0cmFmZmljICsgTU1TIHZlcmlmaWNhdGlvbiAoe3VwZGF0ZS5Qcm9qZWN0aW9uU3RhdHVzfSkiLA0KKyAgICAgICAgICAgICAgICAgICAgc2Vzc2lvbi5TdGF0aWNEYXRhU2V0UmVwb3J0T25seQ0KKyAgICAgICAgICAgICAgICAgICAgICAgID8gc3RhdGUuUmVwb3J0Q2hhbmdlVmVyaWZpZWQNCisgICAgICAgICAgICAgICAgICAgICAgICAgICAgPyBzdHJpbmcuSXNOdWxsT3JXaGl0ZVNwYWNlKHVwZGF0ZS5Qcm9qZWN0aW9uU3RhdHVzKSA/ICJMaXZlIC8gc3RhdGljIHJlcG9ydCB2ZXJpZmllZCIgOiAkIkxpdmUgLyBzdGF0aWMgcmVwb3J0IHZlcmlmaWVkICh7dXBkYXRlLlByb2plY3Rpb25TdGF0dXN9KSINCisgICAgICAgICAgICAgICAgICAgICAgICAgICAgOiBzdHJpbmcuSXNOdWxsT3JXaGl0ZVNwYWNlKHVwZGF0ZS5Qcm9qZWN0aW9uU3RhdHVzKSA/ICJMaXZlIC8gc3RhdGljIHJlcG9ydCIgOiAkIkxpdmUgLyBzdGF0aWMgcmVwb3J0ICh7dXBkYXRlLlByb2plY3Rpb25TdGF0dXN9KSINCisgICAgICAgICAgICAgICAgICAgICAgICA6IHN0YXRlLlJlcG9ydENoYW5nZVZlcmlmaWVkDQorICAgICAgICAgICAgICAgICAgICAgICAgICAgID8gc3RyaW5nLklzTnVsbE9yV2hpdGVTcGFjZSh1cGRhdGUuUHJvamVjdGlvblN0YXR1cykgPyAiTGl2ZSAvIHJlcG9ydCB2ZXJpZmllZCIgOiAkIkxpdmUgLyByZXBvcnQgdmVyaWZpZWQgKHt1cGRhdGUuUHJvamVjdGlvblN0YXR1c30pIg0KKyAgICAgICAgICAgICAgICAgICAgICAgICAgICA6IHN0cmluZy5Jc051bGxPcldoaXRlU3BhY2UodXBkYXRlLlByb2plY3Rpb25TdGF0dXMpID8gIkxpdmUgLyByZXBvcnQgdHJhZmZpYyArIE1NUyB2ZXJpZmljYXRpb24iIDogJCJMaXZlIC8gcmVwb3J0IHRyYWZmaWMgKyBNTVMgdmVyaWZpY2F0aW9uICh7dXBkYXRlLlByb2plY3Rpb25TdGF0dXN9KSIsDQogICAgICAgICAgICAgICAgICAgICB0cnVzdFJlcG9ydEVkZ2U6IHRydWUsDQogICAgICAgICAgICAgICAgICAgICBoYXNQcm9jZXNzVmFsdWU6IHVwZGF0ZS5IYXNWYWx1ZSk7DQorICAgICAgICAgICAgICAgIH0NCiAgICAgICAgICAgICB9DQogDQogICAgICAgICAgICAgdmFyIHZlcmlmaWVkUmVwb3J0UG9pbnRLZXlzID0gc2xpY2UuVXBkYXRlcw0KQEAgLTIwMzIsNiArMjA3NSw3IEBACiAgICAgICAgIHNlc3Npb24uQWN0aXZlUmVwb3J0UGxhbnMuQ2xlYXIoKTsNCiAgICAgICAgIHNlc3Npb24uQWN0aXZlUmVwb3J0UGxhbk9yZGVyLkNsZWFyKCk7DQogICAgICAgICBzZXNzaW9uLlBvaW50UGxhbklkcy5DbGVhcigpOw0KKyAgICAgICAgc2Vzc2lvbi5TdGF0aWNSZXBvcnRQcm9qZWN0aW9uLlJlc2V0KCk7DQogICAgICAgICBzZXNzaW9uLlBvbGxRdWV1ZS5DbGVhcigpOw0KICAgICAgICAgc2Vzc2lvbi5SZXBvcnRTdHJlYW1zLkNsZWFyKCk7DQogICAgICAgICBzZXNzaW9uLkxhc3RVbnJvdXRlZFJlcG9ydENvdW50ID0gMDsNCkBAIC0yMTExLDYgKzIxNTUsMzMgQEAKICAgICAgICAgICAgICAgIG5vcm1hbGl6ZWQuQ29udGFpbnMoIi5kbWR3aCIsIFN0cmluZ0NvbXBhcmlzb24uT3JkaW5hbElnbm9yZUNhc2UpOw0KICAgICB9DQogDQorICAgIHByaXZhdGUgc3RhdGljIGJv \ No newline at end of file From 7540df9aa7a4630781cf743b678918170f0b8274 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 16:38:46 +0700 Subject: [PATCH 10/51] Stage reviewed Static DataSet runtime patch 5/5 --- .github/patches/static-report-runtime.part04 | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/patches/static-report-runtime.part04 diff --git a/.github/patches/static-report-runtime.part04 b/.github/patches/static-report-runtime.part04 new file mode 100644 index 000000000..96e87e9ed --- /dev/null +++ b/.github/patches/static-report-runtime.part04 @@ -0,0 +1 @@ +b2wgSGFzU2NoZW1hUHJvdmVuUmVwb3J0VmFsdWVBdXRob3JpdHkoDQorICAgICAgICBJZWM2MTg1ME1vbml0b3JQb2ludCBwb2ludCwNCisgICAgICAgIE5hdGl2ZVJlcG9ydFZhbHVlVXBkYXRlIHVwZGF0ZSkNCisgICAgew0KKyAgICAgICAgdmFyIHByb2plY3Rpb25TdGF0dXMgPSAodXBkYXRlLlByb2plY3Rpb25TdGF0dXMgPz8gc3RyaW5nLkVtcHR5KS5UcmltKCk7DQorICAgICAgICBpZiAocHJvamVjdGlvblN0YXR1cy5FcXVhbHMoInNlbWFudGljLXN0cnVjdHVyZWQtbGVhZiIsIFN0cmluZ0NvbXBhcmlzb24uT3JkaW5hbElnbm9yZUNhc2UpIHx8DQorICAgICAgICAgICAgcHJvamVjdGlvblN0YXR1cy5FcXVhbHMoInNjaGVtYS1zYWZlLXJlcG9ydC10aHJlZS1waGFzZS1hZ2dyZWdhdGUiLCBTdHJpbmdDb21wYXJpc29uLk9yZGluYWxJZ25vcmVDYXNlKSB8fA0KKyAgICAgICAgICAgIHByb2plY3Rpb25TdGF0dXMuRXF1YWxzKCJzY2hlbWEtc2FmZS1yZXBvcnQtZGVtYW5kLWVuZXJneS1hZ2dyZWdhdGUiLCBTdHJpbmdDb21wYXJpc29uLk9yZGluYWxJZ25vcmVDYXNlKSkNCisgICAgICAgIHsNCisgICAgICAgICAgICByZXR1cm4gdHJ1ZTsNCisgICAgICAgIH0NCisNCisgICAgICAgIC8vIEV4YWN0IHJlcG9ydCBpZGVudGl0eSBpcyBhbHNvIGF1dGhvcml0YXRpdmUgZm9yIGEgc2NhbGFyIG1lbWJlci4gVGhpcyBhZG1pdHMNCisgICAgICAgIC8vIGRpcmVjdCBhbmQgZGV0ZXJtaW5pc3RpYyBNWC1wYWlyIHByb2plY3Rpb25zIG9ubHkgd2hlbiBBUklFQyBoYXMgbmFtZWQgZXhhY3RseQ0KKyAgICAgICAgLy8gdGhlIHNhbWUgSUVDIHJlZmVyZW5jZSBzZWxlY3RlZCBieSB0aGUgcnVudGltZTsgcGFyZW50L2NoaWxkIHByZWZpeCBjb2luY2lkZW5jZQ0KKyAgICAgICAgLy8gYWxvbmUgaXMgbm90IHN1ZmZpY2llbnQuDQorICAgICAgICBpZiAoIUNhbm9uaWNhbERhdGFSZWZlcmVuY2UocG9pbnQuSWVjUmVmZXJlbmNlKQ0KKyAgICAgICAgICAgICAgICAuRXF1YWxzKENhbm9uaWNhbERhdGFSZWZlcmVuY2UodXBkYXRlLlJlZmVyZW5jZSksIFN0cmluZ0NvbXBhcmlzb24uT3JkaW5hbElnbm9yZUNhc2UpKQ0KKyAgICAgICAgew0KKyAgICAgICAgICAgIHJldHVybiBmYWxzZTsNCisgICAgICAgIH0NCisNCisgICAgICAgIHJldHVybiBwcm9qZWN0aW9uU3RhdHVzLkxlbmd0aCA9PSAwIHx8DQorICAgICAgICAgICAgICAgcHJvamVjdGlvblN0YXR1cy5FcXVhbHMoImRpcmVjdCIsIFN0cmluZ0NvbXBhcmlzb24uT3JkaW5hbElnbm9yZUNhc2UpIHx8DQorICAgICAgICAgICAgICAgcHJvamVjdGlvblN0YXR1cy5FcXVhbHMoInByb2plY3RlZC1teC1wYWlyIiwgU3RyaW5nQ29tcGFyaXNvbi5PcmRpbmFsSWdub3JlQ2FzZSk7DQorICAgIH0NCisNCiAgICAgcHJpdmF0ZSBzdGF0aWMgYm9vbCBIYXNFeGFjdFNlbWFudGljRWRnZShJZWM2MTg1ME1vbml0b3JQb2ludCBwb2ludCwgc3RyaW5nIG9sZFZhbHVlLCBzdHJpbmcgbmV3VmFsdWUpDQogICAgIHsNCiAgICAgICAgIHZhciBvbGRUZXh0ID0gKG9sZFZhbHVlID8/IHN0cmluZy5FbXB0eSkuVHJpbSgpOw0KQEAgLTIyNTcsNiArMjMyOCwxMyBAQAogICAgICAgICAgICAgc3RhdGUuUmVwb3J0VHJhZmZpY1NlZW4sDQogICAgICAgICAgICAgc3RhdGUuUmVwb3J0Q2hhbmdlVmVyaWZpZWQpOw0KIA0KKyAgICBwcml2YXRlIHN0YXRpYyBzdHJpbmcgQnVpbGRTdGF0aWNEYXRhU2V0QWNxdWlzaXRpb25MYWJlbChSZXBvcnRDb250cm9sUGxhbiBwbGFuKQ0KKyAgICB7DQorICAgICAgICBpZiAoIXN0cmluZy5Jc051bGxPcldoaXRlU3BhY2UocGxhbi5FbmdpbmVBY3F1aXNpdGlvbktpbmQpKQ0KKyAgICAgICAgICAgIHJldHVybiAkIlN0YXRpYyBEYXRhU2V0OiB7cGxhbi5FbmdpbmVBY3F1aXNpdGlvbktpbmR9IjsNCisgICAgICAgIHJldHVybiBwbGFuLkJ1ZmZlcmVkID8gIlN0YXRpYyBEYXRhU2V0OiBTdGF0aWNCcmNiIiA6ICJTdGF0aWMgRGF0YVNldDogU3RhdGljVXJjYiI7DQorICAgIH0NCisNCiAgICAgcHJpdmF0ZSBzdGF0aWMgc3RyaW5nIEJ1aWxkUGxhbkFjcXVpc2l0aW9uTGFiZWwoUmVwb3J0Q29udHJvbFBsYW4gcGxhbiwgYm9vbCBkeW5hbWljRGF0YVNldCkNCiAgICAgew0KICAgICAgICAgdmFyIHJlZmVyZW5jZSA9IChwbGFuLlJlcG9ydENvbnRyb2xSZWZlcmVuY2UgPz8gc3RyaW5nLkVtcHR5KS5UcmltKCkuUmVwbGFjZSgnJCcsICcuJyk7DQo= \ No newline at end of file From 0142f3994d0f490a8bda0aa50224c59ac66b7659 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 16:38:57 +0700 Subject: [PATCH 11/51] Apply reviewed Static DataSet runtime wiring --- ...static-dataset-report-projection-patch.yml | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .github/workflows/apply-static-dataset-report-projection-patch.yml diff --git a/.github/workflows/apply-static-dataset-report-projection-patch.yml b/.github/workflows/apply-static-dataset-report-projection-patch.yml new file mode 100644 index 000000000..4d09302d9 --- /dev/null +++ b/.github/workflows/apply-static-dataset-report-projection-patch.yml @@ -0,0 +1,52 @@ +name: Apply Static DataSet report projection patch + +on: + push: + branches: + - fix/static-dataset-report-projection-parity + paths: + - .github/workflows/apply-static-dataset-report-projection-patch.yml + +permissions: + contents: write + +jobs: + apply-runtime-patch: + runs-on: ubuntu-latest + steps: + - name: Checkout exact branch + uses: actions/checkout@v4 + with: + ref: fix/static-dataset-report-projection-parity + fetch-depth: 0 + persist-credentials: true + + - name: Reconstruct and apply reviewed runtime patch + shell: bash + run: | + cat .github/patches/static-report-runtime.part00 \ + .github/patches/static-report-runtime.part01 \ + .github/patches/static-report-runtime.part02 \ + .github/patches/static-report-runtime.part03 \ + .github/patches/static-report-runtime.part04 \ + | base64 -d > /tmp/runtime.patch + echo "Runtime patch sha256: $(sha256sum /tmp/runtime.patch | awk '{print $1}')" + test "$(sha256sum /tmp/runtime.patch | awk '{print $1}')" = "384e87fbd2424182cccd6bffac6913c2c6fbfb1ede87423f78f4ae719beb0898" + git apply --check /tmp/runtime.patch + git apply /tmp/runtime.patch + + - name: Commit runtime wiring and remove one-shot staging files + shell: bash + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git rm .github/workflows/apply-static-dataset-report-projection-patch.yml + git rm .github/patches/static-report-runtime.part00 + git rm .github/patches/static-report-runtime.part01 + git rm .github/patches/static-report-runtime.part02 + git rm .github/patches/static-report-runtime.part03 + git rm .github/patches/static-report-runtime.part04 + git add Services/Iec61850MonitorRuntime.cs + git diff --cached --check + git commit -m "Static DataSet: wire schema-safe report projection" + git push origin HEAD:fix/static-dataset-report-projection-parity From 315adae2ba1e3356cd1769ff07411cbf6204a9b7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 16:39:35 +0700 Subject: [PATCH 12/51] Normalize runtime patch line endings before apply --- .../workflows/apply-static-dataset-report-projection-patch.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/apply-static-dataset-report-projection-patch.yml b/.github/workflows/apply-static-dataset-report-projection-patch.yml index 4d09302d9..d56953ee3 100644 --- a/.github/workflows/apply-static-dataset-report-projection-patch.yml +++ b/.github/workflows/apply-static-dataset-report-projection-patch.yml @@ -32,6 +32,7 @@ jobs: | base64 -d > /tmp/runtime.patch echo "Runtime patch sha256: $(sha256sum /tmp/runtime.patch | awk '{print $1}')" test "$(sha256sum /tmp/runtime.patch | awk '{print $1}')" = "384e87fbd2424182cccd6bffac6913c2c6fbfb1ede87423f78f4ae719beb0898" + sed -i 's/\r$//' /tmp/runtime.patch git apply --check /tmp/runtime.patch git apply /tmp/runtime.patch From c527b1c7cc0afa6b00d2b1b3f63da6c45b495d2b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:39:44 +0000 Subject: [PATCH 13/51] Static DataSet: wire schema-safe report projection --- .github/patches/static-report-runtime.part00 | 1 - .github/patches/static-report-runtime.part01 | 1 - .github/patches/static-report-runtime.part02 | 1 - .github/patches/static-report-runtime.part03 | 1 - .github/patches/static-report-runtime.part04 | 1 - ...static-dataset-report-projection-patch.yml | 53 ------ Services/Iec61850MonitorRuntime.cs | 158 +++++++++++++----- 7 files changed, 118 insertions(+), 98 deletions(-) delete mode 100644 .github/patches/static-report-runtime.part00 delete mode 100644 .github/patches/static-report-runtime.part01 delete mode 100644 .github/patches/static-report-runtime.part02 delete mode 100644 .github/patches/static-report-runtime.part03 delete mode 100644 .github/patches/static-report-runtime.part04 delete mode 100644 .github/workflows/apply-static-dataset-report-projection-patch.yml diff --git a/.github/patches/static-report-runtime.part00 b/.github/patches/static-report-runtime.part00 deleted file mode 100644 index b48b34b1b..000000000 --- a/.github/patches/static-report-runtime.part00 +++ /dev/null @@ -1 +0,0 @@ -LS0tIGEvU2VydmljZXMvSWVjNjE4NTBNb25pdG9yUnVudGltZS5jcworKysgYi9TZXJ2aWNlcy9JZWM2MTg1ME1vbml0b3JSdW50aW1lLmNzCkBAIC04Myw2ICs4Myw3IEBACiAgICAgICAgIHB1YmxpYyBzdHJpbmcgSGVhbHRoUHJvYmVQb2ludEtleSB7IGdldDsgc2V0OyB9ID0gc3RyaW5nLkVtcHR5Ow0KICAgICAgICAgcHVibGljIGludCBDb250cm9sQ29tbWFuZEFjdGl2ZTsNCiAgICAgICAgIHB1YmxpYyBIeWJyaWRSZXBvcnRQaHlzaWNhbFZhbGlkYXRpb25UcmFja2VyIEh5YnJpZFZhbGlkYXRpb24geyBnZXQ7IH0gPSBuZXcoKTsNCisgICAgICAgIHB1YmxpYyBTdGF0aWNEYXRhU2V0UmVwb3J0UHJvamVjdGlvbkFjY3VtdWxhdG9yIFN0YXRpY1JlcG9ydFByb2plY3Rpb24geyBnZXQ7IH0gPSBuZXcoKTsNCiAgICAgICAgIHB1YmxpYyBib29sIFN0YXRpY0RhdGFTZXRSZXBvcnRPbmx5IHsgZ2V0OyBzZXQ7IH0NCiAgICAgfQ0KIA0KQEAgLTM5OSw2ICs0MDAsNyBAQAogICAgICAgICBzZXNzaW9uLkNvbnNlY3V0aXZlSGVhbHRoUHJvYmVGYWlsdXJlcyA9IDA7DQogICAgICAgICBzZXNzaW9uLkhlYWx0aFByb2JlUG9pbnRLZXkgPSBzdHJpbmcuRW1wdHk7DQogICAgICAgICBzZXNzaW9uLkh5YnJpZFZhbGlkYXRpb24uUmVzZXQobnVsbCk7DQorICAgICAgICBzZXNzaW9uLlN0YXRpY1JlcG9ydFByb2plY3Rpb24uUmVzZXQoKTsNCiAgICAgICAgIHNlc3Npb24uU3RhdGljRGF0YVNldFJlcG9ydE9ubHkgPSBzdGF0aWNEYXRhU2V0UmVwb3J0T25seTsNCiANCiAgICAgICAgIHZhciBzYWZlUG9sbE1zID0gTWF0aC5DbGFtcChwb2xsaW5nSW50ZXJ2YWxNcyA8PSAwID8gMTAwMCA6IHBvbGxpbmdJbnRlcnZhbE1zLCA1MCwgNjAwMDAwKTsNCkBAIC03NDMsMjIgKzc0NSwyMSBAQAogICAgICAgICAgICAgICAgIHNlc3Npb24uQWN0aXZlUmVwb3J0UGxhbk9yZGVyLkFkZChwbGFuKTsNCiANCiAgICAgICAgICAgICAgICAgdmFyIGNvdmVyZWRQb2ludHMgPSBSZXNvbHZlQ292ZXJlZFBvaW50cyhwbGFuLCByZXN1bHQuQ292ZXJlZFJlZmVyZW5jZXMpOw0KLSAgICAgICAgICAgICAgICB2YXIgYWNxdWlzaXRpb25MYWJlbCA9IHN0cmluZy5Jc051bGxPcldoaXRlU3BhY2UocmVzdWx0LkFjcXVpc2l0aW9uTGFiZWwpDQotICAgICAgICAgICAgICAgICAgICA/IEJ1aWxkUGxhbkFjcXVpc2l0aW9uTGFiZWwocGxhbiwgcmVzdWx0LlVzZWREeW5hbWljRGF0YVNldCkNCi0gICAgICAgICAgICAgICAgICAgIDogcmVzdWx0LkFjcXVpc2l0aW9uTGFiZWw7DQorICAgICAgICAgICAgICAgIHZhciBhY3F1aXNpdGlvbkxhYmVsID0gc2Vzc2lvbi5TdGF0aWNEYXRhU2V0UmVwb3J0T25seQ0KKyAgICAgICAgICAgICAgICAgICAgPyBCdWlsZFN0YXRpY0RhdGFTZXRBY3F1aXNpdGlvbkxhYmVsKHBsYW4pDQorICAgICAgICAgICAgICAgICAgICA6IHN0cmluZy5Jc051bGxPcldoaXRlU3BhY2UocmVzdWx0LkFjcXVpc2l0aW9uTGFiZWwpDQorICAgICAgICAgICAgICAgICAgICAgICAgPyBCdWlsZFBsYW5BY3F1aXNpdGlvbkxhYmVsKHBsYW4sIHJlc3VsdC5Vc2VkRHluYW1pY0RhdGFTZXQpDQorICAgICAgICAgICAgICAgICAgICAgICAgOiByZXN1bHQuQWNxdWlzaXRpb25MYWJlbDsNCiAgICAgICAgICAgICAgICAgZm9yZWFjaCAodmFyIHBvaW50IGluIGNvdmVyZWRQb2ludHMpDQogICAgICAgICAgICAgICAgIHsNCi0gICAgICAgICAgICAgICAgICAgIGlmIChSZXF1aXJlc0V4YWN0TW1zVmFsdWVBdXRob3JpdHkocG9pbnQuSWVjUmVmZXJlbmNlKSkNCi0gICAgICAgICAgICAgICAgICAgIHsNCi0gICAgICAgICAgICAgICAgICAgICAgICBpZiAoc2Vzc2lvbi5TdGF0aWNEYXRhU2V0UmVwb3J0T25seSAmJiBzZXNzaW9uLlN0YXRlcy5UcnlHZXRWYWx1ZShwb2ludC5Qb2ludEtleSwgb3V0IHZhciB1bnJlc29sdmVkU3RhdGUpKQ0KLSAgICAgICAgICAgICAgICAgICAgICAgIHsNCi0gICAgICAgICAgICAgICAgICAgICAgICAgICAgdW5yZXNvbHZlZFN0YXRlLlNvdXJjZU1vZGUgPSAiU3RhdGljIERhdGFTZXQ6IHJlcG9ydCBsZWFmIHVucmVzb2x2ZWQiOw0KLSAgICAgICAgICAgICAgICAgICAgICAgICAgICB1bnJlc29sdmVkU3RhdGUuQWNxdWlzaXRpb25MYWJlbCA9IHVucmVzb2x2ZWRTdGF0ZS5Tb3VyY2VNb2RlOw0KLSAgICAgICAgICAgICAgICAgICAgICAgICAgICB1bnJlc29sdmVkU3RhdGUuUmVhc29uID0gInN0cnVjdHVyZWQgcmVwb3J0IHByb2plY3Rpb24gaXMgbm90IHlldCBzY2hlbWEtcHJvdmVuOyB1bnNhZmUgTU1TIGZhbGxiYWNrIGlzIGRpc2FibGVkIGluIFN0YXRpYyBEYXRhU2V0IG1vZGUiOw0KLSAgICAgICAgICAgICAgICAgICAgICAgICAgICBFbWl0U3RhdHVzU25hcHNob3QocG9pbnQsIHVucmVzb2x2ZWRTdGF0ZSwgIlN0YXRpYyByZXBvcnQgbGVhZiB1bnJlc29sdmVkIC8gbm8gTU1TIGZhbGxiYWNrIiwgIlBlbmRpbmciKTsNCi0gICAgICAgICAgICAgICAgICAgICAgICB9DQorICAgICAgICAgICAgICAgICAgICAvLyBIeWJyaWQvbWFudWFsIG1vZGUgcmV0YWlucyBpdHMgY29uc2VydmF0aXZlIGV4YWN0LU1NUyBhdXRob3JpdHkgcnVsZS4NCisgICAgICAgICAgICAgICAgICAgIC8vIFN0YXRpYyBEYXRhU2V0IHJlcG9ydC1vbmx5IG1vZGUgaXMgZGlmZmVyZW50OiB3aGVuIEFSSUVDIHByb3ZlcyB0aGF0DQorICAgICAgICAgICAgICAgICAgICAvLyB0aGUgY29uZmlndXJlZCBSQ0IgY292ZXJzIHRoZSBsaXRlcmFsIERhdGFTZXQgbWVtYmVyLCBiaW5kIHRoYXQgcG9pbnQNCisgICAgICAgICAgICAgICAgICAgIC8vIHRvIHRoZSByZXBvcnQgcGxhbiBhbmQgbGV0IHNjaGVtYS1zYWZlIHJlcG9ydCBwcm9qZWN0aW9uIGRlY2lkZSB3aGV0aGVyDQorICAgICAgICAgICAgICAgICAgICAvLyBhbiBhcnJpdmluZyB2YWx1ZSBpcyBwdWJsaXNoYWJsZS4gTmV2ZXIgY29udmVydCB0aGlzIGNhc2UgaW50byBNTVMuDQorICAgICAgICAgICAgICAgICAgICBpZiAoIXNlc3Npb24uU3RhdGljRGF0YVNldFJlcG9ydE9ubHkgJiYgUmVxdWlyZXNFeGFjdE1tc1ZhbHVlQXV0aG9yaXR5KHBvaW50LkllY1JlZmVyZW5jZSkpDQogICAgICAgICAgICAgICAgICAgICAgICAgY29udGludWU7DQotICAgICAgICAgICAgICAgICAgICB9DQorDQogICAgICAgICAgICAgICAgICAgICBzZXNzaW9uLlBvaW50UGxhbklkc1twb2ludC5Qb2ludEtleV0gPSBwbGFuLlBsYW5JZDsNCiAgICAgICAgICAgICAgICAgICAgIGlmICghc3RyaW5nLklzTnVsbE9yV2hpdGVTcGFjZShyZXN1bHQuUmVwb3J0Q29udHJvbFJlZmVyZW5jZSkpDQogICAgICAgICAgICAgICAgICAgICAgICAgcG9pbnQuUmVwb3J0Q29udHJvbFJlZmVyZW5jZSA9IHJlc3VsdC5SZXBvcnRDb250cm9sUmVmZXJlbmNlOw0KQEAgLTc2OCw4ICs3NjksMTIgQEAKICAgICAgICAgICAgICAgICAgICAgew0KICAgICAgICAgICAgICAgICAgICAgICAgIHBvaW50U3RhdGUuQWNxdWlzaXRpb25MYWJlbCA9IGFjcXVpc2l0aW9uTGFiZWw7DQogICAgICAgICAgICAgICAgICAgICAgICAgcG9pbnRTdGF0ZS5Tb3VyY2VNb2RlID0gYWNxdWlzaXRpb25MYWJlbDsNCi0gICAgICAgICAgICAgICAgICAgICAgICBwb2ludFN0YXRlLlJlYXNvbiA9ICJhd2FpdGluZyByZXBvcnQgLyBNTVMgaW5pdGlhbCByZWFkIjsNCi0gICAgICAgICAgICAgICAgICAgICAgICBwb2ludFN0YXRlLlN0YXR1cyA9ICJSZXBvcnQgYXJtZWQgLyB2ZXJpZmljYXRpb24gYWN0aXZlIjsNCisg \ No newline at end of file diff --git a/.github/patches/static-report-runtime.part01 b/.github/patches/static-report-runtime.part01 deleted file mode 100644 index 7f775d36f..000000000 --- a/.github/patches/static-report-runtime.part01 +++ /dev/null @@ -1 +0,0 @@ -ICAgICAgICAgICAgICAgICAgICAgICBwb2ludFN0YXRlLlJlYXNvbiA9IHNlc3Npb24uU3RhdGljRGF0YVNldFJlcG9ydE9ubHkNCisgICAgICAgICAgICAgICAgICAgICAgICAgICAgPyAiYXdhaXRpbmcgY29uZmlndXJlZCBzdGF0aWMgcmVwb3J0IC8gR2VuZXJhbCBJbnRlcnJvZ2F0aW9uIg0KKyAgICAgICAgICAgICAgICAgICAgICAgICAgICA6ICJhd2FpdGluZyByZXBvcnQgLyBNTVMgaW5pdGlhbCByZWFkIjsNCisgICAgICAgICAgICAgICAgICAgICAgICBwb2ludFN0YXRlLlN0YXR1cyA9IHNlc3Npb24uU3RhdGljRGF0YVNldFJlcG9ydE9ubHkNCisgICAgICAgICAgICAgICAgICAgICAgICAgICAgPyAiU3RhdGljIHJlcG9ydCBhcm1lZCAvIGF3YWl0aW5nIHZhbHVlIg0KKyAgICAgICAgICAgICAgICAgICAgICAgICAgICA6ICJSZXBvcnQgYXJtZWQgLyB2ZXJpZmljYXRpb24gYWN0aXZlIjsNCiAgICAgICAgICAgICAgICAgICAgIH0NCiAgICAgICAgICAgICAgICAgfQ0KIA0KQEAgLTk1NCwyMiArOTU5LDM3IEBACiAgICAgICAgICAgICAgICAgew0KICAgICAgICAgICAgICAgICAgICAgSXNBdXRob3JpdGF0aXZlID0gdHJ1ZSwNCiAgICAgICAgICAgICAgICAgICAgIEF1dGhvcml0eSA9ICJBUklFQzYxODUwIGh5YnJpZCBhY3F1aXNpdGlvbiIsDQotICAgICAgICAgICAgICAgICAgICBTdGF0dXMgPSAiUGxhbm5lciBmYWlsdXJlIC8gcG9sbGluZyBzYWZlIiwNCi0gICAgICAgICAgICAgICAgICAgIFN1bW1hcnkgPSAkIkFSSUVDIGh5YnJpZCBwbGFubmluZyBmYWlsZWQgY2xvc2VkOiB7ZXguR2V0VHlwZSgpLk5hbWV9OiB7ZXguTWVzc2FnZX0uIE5vIGxvY2FsIFJDQiBoZXVyaXN0aWMgd2FzIHN1YnN0aXR1dGVkOyBib3VuZGVkIE1NUyBwb2xsaW5nIHJlbWFpbnMgYWN0aXZlLiIsDQorICAgICAgICAgICAgICAgICAgICBTdGF0dXMgPSBzZXNzaW9uLlN0YXRpY0RhdGFTZXRSZXBvcnRPbmx5DQorICAgICAgICAgICAgICAgICAgICAgICAgPyAiUGxhbm5lciBmYWlsdXJlIC8gc3RhdGljIHJlcG9ydCB1bmF2YWlsYWJsZSINCisgICAgICAgICAgICAgICAgICAgICAgICA6ICJQbGFubmVyIGZhaWx1cmUgLyBwb2xsaW5nIHNhZmUiLA0KKyAgICAgICAgICAgICAgICAgICAgU3VtbWFyeSA9IHNlc3Npb24uU3RhdGljRGF0YVNldFJlcG9ydE9ubHkNCisgICAgICAgICAgICAgICAgICAgICAgICA/ICQiQVJJRUMgcmVwb3J0IHBsYW5uaW5nIGZhaWxlZCBjbG9zZWQ6IHtleC5HZXRUeXBlKCkuTmFtZX06IHtleC5NZXNzYWdlfS4gTm8gbG9jYWwgUkNCIGhldXJpc3RpYyB3YXMgc3Vic3RpdHV0ZWQgYW5kIE1NUyBwcm9jZXNzIGZhbGxiYWNrIHJlbWFpbnMgZGlzYWJsZWQgYnkgU3RhdGljIERhdGFTZXQgbW9kZS4iDQorICAgICAgICAgICAgICAgICAgICAgICAgOiAkIkFSSUVDIGh5YnJpZCBwbGFubmluZyBmYWlsZWQgY2xvc2VkOiB7ZXguR2V0VHlwZSgpLk5hbWV9OiB7ZXguTWVzc2FnZX0uIE5vIGxvY2FsIFJDQiBoZXVyaXN0aWMgd2FzIHN1YnN0aXR1dGVkOyBib3VuZGVkIE1NUyBwb2xsaW5nIHJlbWFpbnMgYWN0aXZlLiIsDQogICAgICAgICAgICAgICAgICAgICBSZXF1ZXN0ZWRQb2ludENvdW50ID0gc2Vzc2lvbi5Qb2ludHMuQ291bnQsDQotICAgICAgICAgICAgICAgICAgICBQb2xsaW5nUG9pbnRLZXlzID0gc2Vzc2lvbi5Qb2ludHMuS2V5cy5Ub0FycmF5KCksDQotICAgICAgICAgICAgICAgICAgICBQb2xsaW5nRmFsbGJhY2tTaWduYWxDb3VudCA9IHNlc3Npb24uUG9pbnRzLkNvdW50LA0KKyAgICAgICAgICAgICAgICAgICAgUG9sbGluZ1BvaW50S2V5cyA9IHNlc3Npb24uU3RhdGljRGF0YVNldFJlcG9ydE9ubHkgPyBBcnJheS5FbXB0eTxzdHJpbmc+KCkgOiBzZXNzaW9uLlBvaW50cy5LZXlzLlRvQXJyYXkoKSwNCisgICAgICAgICAgICAgICAgICAgIFBvbGxpbmdGYWxsYmFja1NpZ25hbENvdW50ID0gc2Vzc2lvbi5TdGF0aWNEYXRhU2V0UmVwb3J0T25seSA/IDAgOiBzZXNzaW9uLlBvaW50cy5Db3VudCwNCisgICAgICAgICAgICAgICAgICAgIFVuY292ZXJlZFNpZ25hbENvdW50ID0gc2Vzc2lvbi5TdGF0aWNEYXRhU2V0UmVwb3J0T25seSA/IHNlc3Npb24uUG9pbnRzLkNvdW50IDogMCwNCiAgICAgICAgICAgICAgICAgICAgIFdhcm5pbmdzID0gWyQiSHlicmlkIHBsYW5uaW5nIGV4Y2VwdGlvbjoge2V4LkdldFR5cGUoKS5OYW1lfToge2V4Lk1lc3NhZ2V9Il0NCiAgICAgICAgICAgICAgICAgfTsNCiAgICAgICAgICAgICB9DQogDQogICAgICAgICAgICAgc2Vzc2lvbi5IeWJyaWRWYWxpZGF0aW9uLlJlc2V0KGh5YnJpZCk7DQotICAgICAgICAgICAgTG9nKCJJTkZPIiwgc2Vzc2lvbi5EZXZpY2UuTmFtZSwNCi0gICAgICAgICAgICAgICAgJCJIeWJyaWQgYXV0aG9yaXR5PXtoeWJyaWQuQXV0aG9yaXR5fTsgc3RhdHVzPXtoeWJyaWQuU3RhdHVzfTsgcmVxdWVzdGVkPXtoeWJyaWQuUmVxdWVzdGVkUG9pbnRDb3VudH0sIGNhdGFsb2c9e2h5YnJpZC5DYXRhbG9nTWFwcGVkUG9pbnRDb3VudH0sIHN0YXRpY0JSQ0I9e2h5YnJpZC5TdGF0aWNCcmNiU2lnbmFsQ291bnR9LCBzdGF0aWNVUkNCPXtoeWJyaWQuU3RhdGljVXJjYlNpZ25hbENvdW50fSwgZHluYW1pY0JSQ0I9e2h5YnJpZC5EeW5hbWljQnJjYlNpZ25hbENvdW50fSwgZHluYW1pY1VSQ0I9e2h5YnJpZC5EeW5hbWljVXJjYlNpZ25hbENvdW50fSwgcG9sbGluZz17aHlicmlkLlBvbGxpbmdGYWxsYmFja1NpZ25hbENvdW50fSwgdW5jb3ZlcmVkPXtoeWJyaWQuVW5jb3ZlcmVkU2lnbmFsQ291bnR9LiB7aHlicmlkLlN1bW1hcnl9Iik7DQorICAgICAgICAgICAgaWYgKHNlc3Npb24uU3RhdGljRGF0YVNldFJlcG9ydE9ubHkpDQorICAgICAgICAgICAgew0KKyAgICAgICAgICAgICAgICBMb2coIklORk8iLCBzZXNzaW9uLkRldmljZS5OYW1lLA0KKyAgICAgICAgICAgICAgICAgICAgJCJTdGF0aWMgRGF0YVNldCBBUklFQyBhdXRob3JpdHk9e2h5YnJpZC5BdXRob3JpdHl9OyBzdGF0dXM9e2h5YnJpZC5TdGF0dXN9OyByZXF1ZXN0ZWQ9e2h5YnJpZC5SZXF1ZXN0ZWRQb2ludENvdW50fSwgY2F0YWxvZz17aHlicmlkLkNhdGFsb2dNYXBwZWRQb2ludENvdW50fSwgc3RhdGljQlJDQj17aHlicmlkLlN0YXRpY0JyY2JTaWduYWxDb3VudH0sIHN0YXRpY1VSQ0I9e2h5YnJpZC5TdGF0aWNVcmNiU2lnbmFsQ291bnR9LCBlbmdpbmVGYWxsYmFja0NhbmRpZGF0ZXM9e2h5YnJpZC5Qb2xsaW5nRmFsbGJhY2tTaWduYWxDb3VudH0sIHVuY292ZXJlZD17aHlicmlkLlVuY292ZXJlZFNpZ25hbENvdW50fS4gRW5naW5lIGZhbGxiYWNrIGNhbmRpZGF0ZXMgYXJlIGRpYWdub3N0aWMgb25seSBhbmQgYXJlIG5vdCBzY2hlZHVsZWQgYXMgTU1TIHByb2Nlc3MgcG9sbGluZy4iKTsNCisgICAgICAgICAgICB9DQorICAgICAgICAgICAgZWxzZQ0KKyAgICAgICAgICAgIHsNCisgICAgICAgICAgICAgICAgTG9nKCJJTkZPIiwgc2Vzc2lvbi5EZXZpY2UuTmFtZSwNCisgICAgICAgICAgICAgICAgICAgICQiSHlicmlkIGF1dGhvcml0eT17aHlicmlkLkF1dGhvcml0eX07IHN0YXR1cz17aHlicmlkLlN0YXR1c307IHJlcXVlc3RlZD17aHlicmlkLlJlcXVlc3RlZFBvaW50Q291bnR9LCBjYXRhbG9nPXtoeWJyaWQuQ2F0YWxvZ01hcHBlZFBvaW50Q291bnR9LCBzdGF0aWNCUkNCPXtoeWJyaWQuU3RhdGljQnJjYlNpZ25hbENvdW50fSwgc3RhdGljVVJDQj17aHlicmlkLlN0YXRpY1VyY2JTaWduYWxDb3VudH0sIGR5bmFtaWNCUkNC \ No newline at end of file diff --git a/.github/patches/static-report-runtime.part02 b/.github/patches/static-report-runtime.part02 deleted file mode 100644 index c423630dc..000000000 --- a/.github/patches/static-report-runtime.part02 +++ /dev/null @@ -1 +0,0 @@ -PXtoeWJyaWQuRHluYW1pY0JyY2JTaWduYWxDb3VudH0sIGR5bmFtaWNVUkNCPXtoeWJyaWQuRHluYW1pY1VyY2JTaWduYWxDb3VudH0sIHBvbGxpbmc9e2h5YnJpZC5Qb2xsaW5nRmFsbGJhY2tTaWduYWxDb3VudH0sIHVuY292ZXJlZD17aHlicmlkLlVuY292ZXJlZFNpZ25hbENvdW50fS4ge2h5YnJpZC5TdW1tYXJ5fSIpOw0KKyAgICAgICAgICAgIH0NCiAgICAgICAgICAgICBmb3JlYWNoICh2YXIgd2FybmluZyBpbiBoeWJyaWQuV2FybmluZ3MuVGFrZSg1KSkNCiAgICAgICAgICAgICAgICAgTG9nKCJXQVJOIiwgc2Vzc2lvbi5EZXZpY2UuTmFtZSwgd2FybmluZyk7DQogDQotICAgICAgICAgICAgcmV0dXJuIGh5YnJpZC5SZXBvcnRQbGFuczsNCisgICAgICAgICAgICByZXR1cm4gc2Vzc2lvbi5TdGF0aWNEYXRhU2V0UmVwb3J0T25seQ0KKyAgICAgICAgICAgICAgICA/IGh5YnJpZC5SZXBvcnRQbGFucy5XaGVyZShwbGFuID0+ICFwbGFuLkFsbG93RHluYW1pY0RhdGFTZXRXcml0ZXMpLlRvQXJyYXkoKQ0KKyAgICAgICAgICAgICAgICA6IGh5YnJpZC5SZXBvcnRQbGFuczsNCiAgICAgICAgIH0NCiANCiAgICAgICAgIHNlc3Npb24uSHlicmlkVmFsaWRhdGlvbi5SZXNldChudWxsKTsNCkBAIC0xMDUxLDIyICsxMDcxLDM3IEBACiAgICAgICAgICAgICBpZiAoc2xpY2UuUmVwb3J0RnJhbWVzLkNvdW50ID4gMCB8fCBzbGljZS5VcGRhdGVzLkNvdW50ID4gMCkNCiAgICAgICAgICAgICAgICAgUmVjb3JkU3VjY2Vzc2Z1bElvKHNlc3Npb24pOw0KIA0KLSAgICAgICAgICAgIGZvcmVhY2ggKHZhciB1cGRhdGUgaW4gc2xpY2UuVXBkYXRlcykNCisgICAgICAgICAgICBmb3JlYWNoICh2YXIgc291cmNlVXBkYXRlIGluIHNsaWNlLlVwZGF0ZXMpDQogICAgICAgICAgICAgew0KLSAgICAgICAgICAgICAgICB2YXIgcG9pbnQgPSBGaW5kUG9pbnRGb3JSZXBvcnRSZWZlcmVuY2Uoc2Vzc2lvbiwgdXBkYXRlLlJlZmVyZW5jZSk7DQotICAgICAgICAgICAgICAgIGlmIChwb2ludCA9PSBudWxsKQ0KLSAgICAgICAgICAgICAgICAgICAgY29udGludWU7DQorICAgICAgICAgICAgICAgIHZhciBlZmZlY3RpdmVVcGRhdGVzID0gc2Vzc2lvbi5TdGF0aWNEYXRhU2V0UmVwb3J0T25seQ0KKyAgICAgICAgICAgICAgICAgICAgPyBzZXNzaW9uLlN0YXRpY1JlcG9ydFByb2plY3Rpb24uUHJvamVjdCgNCisgICAgICAgICAgICAgICAgICAgICAgICBzZXNzaW9uLkRldmljZS5MaXZlRGlzY292ZXJ5TW9kZWwgPz8gc2Vzc2lvbi5EZXZpY2UuU2NsV29ya3NwYWNlPy5EZXNpZ25Nb2RlbCwNCisgICAgICAgICAgICAgICAgICAgICAgICBzZXNzaW9uLlBvaW50cy5WYWx1ZXMuVG9BcnJheSgpLA0KKyAgICAgICAgICAgICAgICAgICAgICAgIHNvdXJjZVVwZGF0ZSkNCisgICAgICAgICAgICAgICAgICAgIDogbmV3W10geyBzb3VyY2VVcGRhdGUgfTsNCiANCi0gICAgICAgICAgICAgICAgLy8gU2llbWVucyBzdGF0aWMgRGF0YVNldHMgcmVwb3J0IFRIRCBhcyBhIG5lc3RlZCB0aHJlZS1waGFzZSBzdHJ1Y3R1cmUuDQotICAgICAgICAgICAgICAgIC8vIFVudGlsIGl0cyBzY2FsYXIgZmFuLW91dCBpcyBzY2hlbWEtcHJvdmVuLCBleGFjdCBNTVMgcmVhZHMgcmVtYWluIHRoZQ0KLSAgICAgICAgICAgICAgICAvLyB2YWx1ZSBhdXRob3JpdHkgc28gYSByZXBvcnQgcHJvamVjdGlvbiBjYW5ub3Qgb3ZlcndyaXRlIGNvcnJlY3QgdmFsdWVzLg0KLSAgICAgICAgICAgICAgICBpZiAoUmVxdWlyZXNFeGFjdE1tc1ZhbHVlQXV0aG9yaXR5KHBvaW50LkllY1JlZmVyZW5jZSkpDQotICAgICAgICAgICAgICAgICAgICBjb250aW51ZTsNCisgICAgICAgICAgICAgICAgZm9yZWFjaCAodmFyIHVwZGF0ZSBpbiBlZmZlY3RpdmVVcGRhdGVzKQ0KKyAgICAgICAgICAgICAgICB7DQorICAgICAgICAgICAgICAgICAgICB2YXIgcG9pbnQgPSBGaW5kUG9pbnRGb3JSZXBvcnRSZWZlcmVuY2Uoc2Vzc2lvbiwgdXBkYXRlLlJlZmVyZW5jZSk7DQorICAgICAgICAgICAgICAgICAgICBpZiAocG9pbnQgPT0gbnVsbCkNCisgICAgICAgICAgICAgICAgICAgICAgICBjb250aW51ZTsNCisNCisgICAgICAgICAgICAgICAgICAgIC8vIFRoZSBvbGQgcnVsZSByZWplY3RlZCBldmVyeSBUSEQvRG1kV2ggcmVwb3J0IHZhbHVlIG1lcmVseSBiZWNhdXNlDQorICAgICAgICAgICAgICAgICAgICAvLyB0aG9zZSBmYW1pbGllcyBvbmNlIHJlcXVpcmVkIE1NUyByZWFkcy4gQVJJRUMgbm93IHByb3ZpZGVzIGV4YWN0DQorICAgICAgICAgICAgICAgICAgICAvLyBzZW1hbnRpYyBsZWFmIGlkZW50aXRpZXMsIGFuZCBTdGF0aWNEYXRhU2V0UmVwb3J0UHJvamVjdGlvbkFjY3VtdWxhdG9yDQorICAgICAgICAgICAgICAgICAgICAvLyByZWNvbnN0cnVjdHMgc3RydWN0dXJlZCBwYXJlbnRzIGZyb20gc2NoZW1hLW5hbWVkIGxlYXZlcy4gS2VlcCB0aGUNCisgICAgICAgICAgICAgICAgICAgIC8vIGxlZ2FjeSB2ZXRvIG9ubHkgd2hlbiB0aGUgcmVwb3J0IHVwZGF0ZSBpdHNlbGYgaXMgbm90IHNjaGVtYS1wcm92ZW4uDQorICAgICAgICAgICAgICAgICAgICBpZiAoUmVxdWlyZXNFeGFjdE1tc1ZhbHVlQXV0aG9yaXR5KHBvaW50LkllY1JlZmVyZW5jZSkgJiYNCisgICAgICAgICAgICAgICAgICAgICAgICAhSGFzU2NoZW1hUHJvdmVuUmVwb3J0VmFsdWVBdXRob3JpdHkocG9pbnQsIHVwZGF0ZSkpDQorICAgICAgICAgICAgICAgICAgICB7DQorICAgICAgICAgICAgICAgICAgICAgICAgY29udGludWU7DQorICAgICAgICAgICAgICAgICAgICB9DQogDQotICAgICAgICAgICAgICAgIC8vIEEgcmVhbCByZXBvcnQgdXBkYXRlIHByb3ZlcyByZWZlcmVuY2UgY292ZXJhZ2UsIGJ1dCBhbiBpbml0aWFsIEdJIG9yDQotICAgICAgICAgICAgICAgIC8vIGludGVncml0eSBpbWFnZSBkb2VzIG5vdCB5ZXQgcHJvdmUgdGhhdCBkY2hnL3FjaGcgaXMgd29ya2luZy4gS2VlcCBNTVMNCi0gICAgICAgICAgICAgICAgLy8gdmVyaWZpY2F0aW9uIGFjdGl2ZSB1bnRpbCBhIGNoYW5nZSByZXBvcnQgaXMgYWN0dWFsbHkgb2JzZXJ2ZWQuDQotICAgICAgICAgICAgICAgIHNlc3Npb24uUG9pbnRQbGFuSWRzW3BvaW50LlBvaW50S2V5XSA9IHBsYW4uUGxhbklkOw0KKyAgICAgICAgICAgICAgICAgICAgLy8gQSByZWFsIHJlcG9ydCB1cGRhdGUgcHJvdmVzIHJlZmVyZW5jZSBjb3ZlcmFnZS4gSW4gSHlicmlkIG1vZGUgYW4NCisgICAgICAgICAgICAgICAgICAgIC8vIGluaXRpYWwgR0kvaW50ZWdyaXR5IGltYWdlIHN0aWxsIGtlZXBzIE1NUyB2ZXJpZmljYXRpb24gYWN0aXZlIHVudGlsDQorICAgICAgICAgICAgICAgICAgICAvLyBkY2hnL3FjaGcgaXMgcHJvdmVuOyBTdGF0aWMgRGF0YVNldCBtb2RlIHB1Ymxpc2hlcyB0aGUgc2NoZW1hLXNhZmUgR0kNCisgICAgICAgICAgICAgICAgICAgIC8vIGltYWdlIGRpcmVjdGx5IGJlY2F1c2UgY3ljbGljIE1NUyBwcm9jZXNzIHZlcmlmaWNhdGlvbiBpcyBkaXNhYmxlZC4NCisgICAgICAgICAgICAgICAgICAgIHNlc3Npb24uUG9pbnRQbGFuSWRzW3BvaW50LlBvaW50S2V5XSA9IHBsYW4uUGxhbklkOw0KIA0KICAgICAgICAgICAgICAgICB2YXIgc3RhdGUgPSBzZXNzaW9uLlN0YXRlc1twb2ludC5Qb2ludEtleV07DQogICAgICAgICAgICAgICAgIHZhciBkaXNwbGF5ID0gdXBkYXRlLkhhc1ZhbHVlDQpAQCAtMTA4NCw3ICsxMTE5LDggQEAKICAgICAgICAgICAgICAgICB7DQogICAgICAgICAgICAgICAgICAgICAvLyBBIG1h \ No newline at end of file diff --git a/.github/patches/static-report-runtime.part03 b/.github/patches/static-report-runtime.part03 deleted file mode 100644 index 0d99c256f..000000000 --- a/.github/patches/static-report-runtime.part03 +++ /dev/null @@ -1 +0,0 @@ -bGZvcm1lZC9taXNhbGlnbmVkIHJlcG9ydCBpcyBzdGlsbCB1c2VmdWwgYXMgcHJvb2YgdGhhdCB0aGUNCiAgICAgICAgICAgICAgICAgICAgIC8vIFJDQiBpcyBhbGl2ZSwgYnV0IGl0cyBwcm9jZXNzIHZhbHVlIGlzIG5vdCBhdXRob3JpdGF0aXZlLiBEbyBub3QNCi0gICAgICAgICAgICAgICAgICAgIC8vIG11dGF0ZSBzdGF0ZSBvciBTT0UgaGlzdG9yeTsga2VlcCBNTVMgdmVyaWZpY2F0aW9uL2ZhbGxiYWNrIGFjdGl2ZS4NCisgICAgICAgICAgICAgICAgICAgIC8vIG11dGF0ZSBzdGF0ZSBvciBTT0UgaGlzdG9yeS4gSHlicmlkIG1vZGUga2VlcHMgTU1TIHZlcmlmaWNhdGlvbjsNCisgICAgICAgICAgICAgICAgICAgIC8vIFN0YXRpYyBEYXRhU2V0IG1vZGUgc3RheXMgcmVwb3J0LXBlbmRpbmcgd2l0aG91dCBhbnkgTU1TIGZhbGxiYWNrLg0KICAgICAgICAgICAgICAgICAgICAgc3RhdGUuUmVwb3J0VHJhZmZpY1NlZW4gPSB0cnVlOw0KICAgICAgICAgICAgICAgICAgICAgc3RhdGUuTGFzdFJlcG9ydFV0YyA9IERhdGVUaW1lLlV0Y05vdzsNCiAgICAgICAgICAgICAgICAgICAgIHN0YXRlLlJlcG9ydENoYW5nZVZlcmlmaWVkID0gZmFsc2U7DQpAQCAtMTA5Niw3ICsxMTMyLDkgQEAKICAgICAgICAgICAgICAgICAgICAgICAgIGlmIChyYXdTdW1tYXJ5Lkxlbmd0aCA+IDEyMCkNCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgcmF3U3VtbWFyeSA9IHJhd1N1bW1hcnlbLi4xMjBdICsgIuKApiI7DQogICAgICAgICAgICAgICAgICAgICAgICAgTG9nKCJXQVJOIiwgc2Vzc2lvbi5EZXZpY2UuTmFtZSwNCi0gICAgICAgICAgICAgICAgICAgICAgICAgICAgJCJSRVBPUlRfVkFMVUVfUkVKRUNURUQ6IHtwb2ludC5TaWduYWxOYW1lfSAoe3BvaW50LkllY1JlZmVyZW5jZX0pIHJlamVjdGVkIHJlcG9ydCB2YWx1ZSAne3Jhd1N1bW1hcnl9Jy4ge3JlamVjdGlvblJlYXNvbn0gTU1TIHZlcmlmaWNhdGlvbi9mYWxsYmFjayByZW1haW5zIGF1dGhvcml0YXRpdmUuIik7DQorICAgICAgICAgICAgICAgICAgICAgICAgICAgIHNlc3Npb24uU3RhdGljRGF0YVNldFJlcG9ydE9ubHkNCisgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgID8gJCJSRVBPUlRfVkFMVUVfUkVKRUNURUQ6IHtwb2ludC5TaWduYWxOYW1lfSAoe3BvaW50LkllY1JlZmVyZW5jZX0pIHJlamVjdGVkIHJlcG9ydCB2YWx1ZSAne3Jhd1N1bW1hcnl9Jy4ge3JlamVjdGlvblJlYXNvbn0gVGhlIHBvaW50IHJlbWFpbnMgcmVwb3J0LXBlbmRpbmc7IE1NUyBwcm9jZXNzIGZhbGxiYWNrIGlzIGRpc2FibGVkLiINCisgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDogJCJSRVBPUlRfVkFMVUVfUkVKRUNURUQ6IHtwb2ludC5TaWduYWxOYW1lfSAoe3BvaW50LkllY1JlZmVyZW5jZX0pIHJlamVjdGVkIHJlcG9ydCB2YWx1ZSAne3Jhd1N1bW1hcnl9Jy4ge3JlamVjdGlvblJlYXNvbn0gTU1TIHZlcmlmaWNhdGlvbi9mYWxsYmFjayByZW1haW5zIGF1dGhvcml0YXRpdmUuIik7DQogICAgICAgICAgICAgICAgICAgICB9DQogICAgICAgICAgICAgICAgICAgICBjb250aW51ZTsNCiAgICAgICAgICAgICAgICAgfQ0KQEAgLTExNjgsMTEgKzEyMDYsMTYgQEAKICAgICAgICAgICAgICAgICAgICAgcmVwb3J0U291cmNlLA0KICAgICAgICAgICAgICAgICAgICAgc3RyaW5nLklzTnVsbE9yV2hpdGVTcGFjZSh1cGRhdGUuUmVhc29uKSA/ICJyZXBvcnQgLyByZWFzb24gbm90IHN1cHBsaWVkIiA6IHVwZGF0ZS5SZWFzb24sDQogICAgICAgICAgICAgICAgICAgICByZWNlaXZlZFV0YywNCi0gICAgICAgICAgICAgICAgICAgIHN0YXRlLlJlcG9ydENoYW5nZVZlcmlmaWVkDQotICAgICAgICAgICAgICAgICAgICAgICAgPyBzdHJpbmcuSXNOdWxsT3JXaGl0ZVNwYWNlKHVwZGF0ZS5Qcm9qZWN0aW9uU3RhdHVzKSA/ICJMaXZlIC8gcmVwb3J0IHZlcmlmaWVkIiA6ICQiTGl2ZSAvIHJlcG9ydCB2ZXJpZmllZCAoe3VwZGF0ZS5Qcm9qZWN0aW9uU3RhdHVzfSkiDQotICAgICAgICAgICAgICAgICAgICAgICAgOiBzdHJpbmcuSXNOdWxsT3JXaGl0ZVNwYWNlKHVwZGF0ZS5Qcm9qZWN0aW9uU3RhdHVzKSA/ICJMaXZlIC8gcmVwb3J0IHRyYWZmaWMgKyBNTVMgdmVyaWZpY2F0aW9uIiA6ICQiTGl2ZSAvIHJlcG9ydCB0cmFmZmljICsgTU1TIHZlcmlmaWNhdGlvbiAoe3VwZGF0ZS5Qcm9qZWN0aW9uU3RhdHVzfSkiLA0KKyAgICAgICAgICAgICAgICAgICAgc2Vzc2lvbi5TdGF0aWNEYXRhU2V0UmVwb3J0T25seQ0KKyAgICAgICAgICAgICAgICAgICAgICAgID8gc3RhdGUuUmVwb3J0Q2hhbmdlVmVyaWZpZWQNCisgICAgICAgICAgICAgICAgICAgICAgICAgICAgPyBzdHJpbmcuSXNOdWxsT3JXaGl0ZVNwYWNlKHVwZGF0ZS5Qcm9qZWN0aW9uU3RhdHVzKSA/ICJMaXZlIC8gc3RhdGljIHJlcG9ydCB2ZXJpZmllZCIgOiAkIkxpdmUgLyBzdGF0aWMgcmVwb3J0IHZlcmlmaWVkICh7dXBkYXRlLlByb2plY3Rpb25TdGF0dXN9KSINCisgICAgICAgICAgICAgICAgICAgICAgICAgICAgOiBzdHJpbmcuSXNOdWxsT3JXaGl0ZVNwYWNlKHVwZGF0ZS5Qcm9qZWN0aW9uU3RhdHVzKSA/ICJMaXZlIC8gc3RhdGljIHJlcG9ydCIgOiAkIkxpdmUgLyBzdGF0aWMgcmVwb3J0ICh7dXBkYXRlLlByb2plY3Rpb25TdGF0dXN9KSINCisgICAgICAgICAgICAgICAgICAgICAgICA6IHN0YXRlLlJlcG9ydENoYW5nZVZlcmlmaWVkDQorICAgICAgICAgICAgICAgICAgICAgICAgICAgID8gc3RyaW5nLklzTnVsbE9yV2hpdGVTcGFjZSh1cGRhdGUuUHJvamVjdGlvblN0YXR1cykgPyAiTGl2ZSAvIHJlcG9ydCB2ZXJpZmllZCIgOiAkIkxpdmUgLyByZXBvcnQgdmVyaWZpZWQgKHt1cGRhdGUuUHJvamVjdGlvblN0YXR1c30pIg0KKyAgICAgICAgICAgICAgICAgICAgICAgICAgICA6IHN0cmluZy5Jc051bGxPcldoaXRlU3BhY2UodXBkYXRlLlByb2plY3Rpb25TdGF0dXMpID8gIkxpdmUgLyByZXBvcnQgdHJhZmZpYyArIE1NUyB2ZXJpZmljYXRpb24iIDogJCJMaXZlIC8gcmVwb3J0IHRyYWZmaWMgKyBNTVMgdmVyaWZpY2F0aW9uICh7dXBkYXRlLlByb2plY3Rpb25TdGF0dXN9KSIsDQogICAgICAgICAgICAgICAgICAgICB0cnVzdFJlcG9ydEVkZ2U6IHRydWUsDQogICAgICAgICAgICAgICAgICAgICBoYXNQcm9jZXNzVmFsdWU6IHVwZGF0ZS5IYXNWYWx1ZSk7DQorICAgICAgICAgICAgICAgIH0NCiAgICAgICAgICAgICB9DQogDQogICAgICAgICAgICAgdmFyIHZlcmlmaWVkUmVwb3J0UG9pbnRLZXlzID0gc2xpY2UuVXBkYXRlcw0KQEAgLTIwMzIsNiArMjA3NSw3IEBACiAgICAgICAgIHNlc3Npb24uQWN0aXZlUmVwb3J0UGxhbnMuQ2xlYXIoKTsNCiAgICAgICAgIHNlc3Npb24uQWN0aXZlUmVwb3J0UGxhbk9yZGVyLkNsZWFyKCk7DQogICAgICAgICBzZXNzaW9uLlBvaW50UGxhbklkcy5DbGVhcigpOw0KKyAgICAgICAgc2Vzc2lvbi5TdGF0aWNSZXBvcnRQcm9qZWN0aW9uLlJlc2V0KCk7DQogICAgICAgICBzZXNzaW9uLlBvbGxRdWV1ZS5DbGVhcigpOw0KICAgICAgICAgc2Vzc2lvbi5SZXBvcnRTdHJlYW1zLkNsZWFyKCk7DQogICAgICAgICBzZXNzaW9uLkxhc3RVbnJvdXRlZFJlcG9ydENvdW50ID0gMDsNCkBAIC0yMTExLDYgKzIxNTUsMzMgQEAKICAgICAgICAgICAgICAgIG5vcm1hbGl6ZWQuQ29udGFpbnMoIi5kbWR3aCIsIFN0cmluZ0NvbXBhcmlzb24uT3JkaW5hbElnbm9yZUNhc2UpOw0KICAgICB9DQogDQorICAgIHByaXZhdGUgc3RhdGljIGJv \ No newline at end of file diff --git a/.github/patches/static-report-runtime.part04 b/.github/patches/static-report-runtime.part04 deleted file mode 100644 index 96e87e9ed..000000000 --- a/.github/patches/static-report-runtime.part04 +++ /dev/null @@ -1 +0,0 @@ -b2wgSGFzU2NoZW1hUHJvdmVuUmVwb3J0VmFsdWVBdXRob3JpdHkoDQorICAgICAgICBJZWM2MTg1ME1vbml0b3JQb2ludCBwb2ludCwNCisgICAgICAgIE5hdGl2ZVJlcG9ydFZhbHVlVXBkYXRlIHVwZGF0ZSkNCisgICAgew0KKyAgICAgICAgdmFyIHByb2plY3Rpb25TdGF0dXMgPSAodXBkYXRlLlByb2plY3Rpb25TdGF0dXMgPz8gc3RyaW5nLkVtcHR5KS5UcmltKCk7DQorICAgICAgICBpZiAocHJvamVjdGlvblN0YXR1cy5FcXVhbHMoInNlbWFudGljLXN0cnVjdHVyZWQtbGVhZiIsIFN0cmluZ0NvbXBhcmlzb24uT3JkaW5hbElnbm9yZUNhc2UpIHx8DQorICAgICAgICAgICAgcHJvamVjdGlvblN0YXR1cy5FcXVhbHMoInNjaGVtYS1zYWZlLXJlcG9ydC10aHJlZS1waGFzZS1hZ2dyZWdhdGUiLCBTdHJpbmdDb21wYXJpc29uLk9yZGluYWxJZ25vcmVDYXNlKSB8fA0KKyAgICAgICAgICAgIHByb2plY3Rpb25TdGF0dXMuRXF1YWxzKCJzY2hlbWEtc2FmZS1yZXBvcnQtZGVtYW5kLWVuZXJneS1hZ2dyZWdhdGUiLCBTdHJpbmdDb21wYXJpc29uLk9yZGluYWxJZ25vcmVDYXNlKSkNCisgICAgICAgIHsNCisgICAgICAgICAgICByZXR1cm4gdHJ1ZTsNCisgICAgICAgIH0NCisNCisgICAgICAgIC8vIEV4YWN0IHJlcG9ydCBpZGVudGl0eSBpcyBhbHNvIGF1dGhvcml0YXRpdmUgZm9yIGEgc2NhbGFyIG1lbWJlci4gVGhpcyBhZG1pdHMNCisgICAgICAgIC8vIGRpcmVjdCBhbmQgZGV0ZXJtaW5pc3RpYyBNWC1wYWlyIHByb2plY3Rpb25zIG9ubHkgd2hlbiBBUklFQyBoYXMgbmFtZWQgZXhhY3RseQ0KKyAgICAgICAgLy8gdGhlIHNhbWUgSUVDIHJlZmVyZW5jZSBzZWxlY3RlZCBieSB0aGUgcnVudGltZTsgcGFyZW50L2NoaWxkIHByZWZpeCBjb2luY2lkZW5jZQ0KKyAgICAgICAgLy8gYWxvbmUgaXMgbm90IHN1ZmZpY2llbnQuDQorICAgICAgICBpZiAoIUNhbm9uaWNhbERhdGFSZWZlcmVuY2UocG9pbnQuSWVjUmVmZXJlbmNlKQ0KKyAgICAgICAgICAgICAgICAuRXF1YWxzKENhbm9uaWNhbERhdGFSZWZlcmVuY2UodXBkYXRlLlJlZmVyZW5jZSksIFN0cmluZ0NvbXBhcmlzb24uT3JkaW5hbElnbm9yZUNhc2UpKQ0KKyAgICAgICAgew0KKyAgICAgICAgICAgIHJldHVybiBmYWxzZTsNCisgICAgICAgIH0NCisNCisgICAgICAgIHJldHVybiBwcm9qZWN0aW9uU3RhdHVzLkxlbmd0aCA9PSAwIHx8DQorICAgICAgICAgICAgICAgcHJvamVjdGlvblN0YXR1cy5FcXVhbHMoImRpcmVjdCIsIFN0cmluZ0NvbXBhcmlzb24uT3JkaW5hbElnbm9yZUNhc2UpIHx8DQorICAgICAgICAgICAgICAgcHJvamVjdGlvblN0YXR1cy5FcXVhbHMoInByb2plY3RlZC1teC1wYWlyIiwgU3RyaW5nQ29tcGFyaXNvbi5PcmRpbmFsSWdub3JlQ2FzZSk7DQorICAgIH0NCisNCiAgICAgcHJpdmF0ZSBzdGF0aWMgYm9vbCBIYXNFeGFjdFNlbWFudGljRWRnZShJZWM2MTg1ME1vbml0b3JQb2ludCBwb2ludCwgc3RyaW5nIG9sZFZhbHVlLCBzdHJpbmcgbmV3VmFsdWUpDQogICAgIHsNCiAgICAgICAgIHZhciBvbGRUZXh0ID0gKG9sZFZhbHVlID8/IHN0cmluZy5FbXB0eSkuVHJpbSgpOw0KQEAgLTIyNTcsNiArMjMyOCwxMyBAQAogICAgICAgICAgICAgc3RhdGUuUmVwb3J0VHJhZmZpY1NlZW4sDQogICAgICAgICAgICAgc3RhdGUuUmVwb3J0Q2hhbmdlVmVyaWZpZWQpOw0KIA0KKyAgICBwcml2YXRlIHN0YXRpYyBzdHJpbmcgQnVpbGRTdGF0aWNEYXRhU2V0QWNxdWlzaXRpb25MYWJlbChSZXBvcnRDb250cm9sUGxhbiBwbGFuKQ0KKyAgICB7DQorICAgICAgICBpZiAoIXN0cmluZy5Jc051bGxPcldoaXRlU3BhY2UocGxhbi5FbmdpbmVBY3F1aXNpdGlvbktpbmQpKQ0KKyAgICAgICAgICAgIHJldHVybiAkIlN0YXRpYyBEYXRhU2V0OiB7cGxhbi5FbmdpbmVBY3F1aXNpdGlvbktpbmR9IjsNCisgICAgICAgIHJldHVybiBwbGFuLkJ1ZmZlcmVkID8gIlN0YXRpYyBEYXRhU2V0OiBTdGF0aWNCcmNiIiA6ICJTdGF0aWMgRGF0YVNldDogU3RhdGljVXJjYiI7DQorICAgIH0NCisNCiAgICAgcHJpdmF0ZSBzdGF0aWMgc3RyaW5nIEJ1aWxkUGxhbkFjcXVpc2l0aW9uTGFiZWwoUmVwb3J0Q29udHJvbFBsYW4gcGxhbiwgYm9vbCBkeW5hbWljRGF0YVNldCkNCiAgICAgew0KICAgICAgICAgdmFyIHJlZmVyZW5jZSA9IChwbGFuLlJlcG9ydENvbnRyb2xSZWZlcmVuY2UgPz8gc3RyaW5nLkVtcHR5KS5UcmltKCkuUmVwbGFjZSgnJCcsICcuJyk7DQo= \ No newline at end of file diff --git a/.github/workflows/apply-static-dataset-report-projection-patch.yml b/.github/workflows/apply-static-dataset-report-projection-patch.yml deleted file mode 100644 index d56953ee3..000000000 --- a/.github/workflows/apply-static-dataset-report-projection-patch.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Apply Static DataSet report projection patch - -on: - push: - branches: - - fix/static-dataset-report-projection-parity - paths: - - .github/workflows/apply-static-dataset-report-projection-patch.yml - -permissions: - contents: write - -jobs: - apply-runtime-patch: - runs-on: ubuntu-latest - steps: - - name: Checkout exact branch - uses: actions/checkout@v4 - with: - ref: fix/static-dataset-report-projection-parity - fetch-depth: 0 - persist-credentials: true - - - name: Reconstruct and apply reviewed runtime patch - shell: bash - run: | - cat .github/patches/static-report-runtime.part00 \ - .github/patches/static-report-runtime.part01 \ - .github/patches/static-report-runtime.part02 \ - .github/patches/static-report-runtime.part03 \ - .github/patches/static-report-runtime.part04 \ - | base64 -d > /tmp/runtime.patch - echo "Runtime patch sha256: $(sha256sum /tmp/runtime.patch | awk '{print $1}')" - test "$(sha256sum /tmp/runtime.patch | awk '{print $1}')" = "384e87fbd2424182cccd6bffac6913c2c6fbfb1ede87423f78f4ae719beb0898" - sed -i 's/\r$//' /tmp/runtime.patch - git apply --check /tmp/runtime.patch - git apply /tmp/runtime.patch - - - name: Commit runtime wiring and remove one-shot staging files - shell: bash - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git rm .github/workflows/apply-static-dataset-report-projection-patch.yml - git rm .github/patches/static-report-runtime.part00 - git rm .github/patches/static-report-runtime.part01 - git rm .github/patches/static-report-runtime.part02 - git rm .github/patches/static-report-runtime.part03 - git rm .github/patches/static-report-runtime.part04 - git add Services/Iec61850MonitorRuntime.cs - git diff --cached --check - git commit -m "Static DataSet: wire schema-safe report projection" - git push origin HEAD:fix/static-dataset-report-projection-parity diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 25d8bcfef..0b939ec05 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -83,6 +83,7 @@ private sealed class DeviceSession public string HealthProbePointKey { get; set; } = string.Empty; public int ControlCommandActive; public HybridReportPhysicalValidationTracker HybridValidation { get; } = new(); + public StaticDataSetReportProjectionAccumulator StaticReportProjection { get; } = new(); public bool StaticDataSetReportOnly { get; set; } } @@ -399,6 +400,7 @@ public async Task> StartMonitoringAsync( session.ConsecutiveHealthProbeFailures = 0; session.HealthProbePointKey = string.Empty; session.HybridValidation.Reset(null); + session.StaticReportProjection.Reset(); session.StaticDataSetReportOnly = staticDataSetReportOnly; var safePollMs = Math.Clamp(pollingIntervalMs <= 0 ? 1000 : pollingIntervalMs, 50, 600000); @@ -743,22 +745,21 @@ private async Task StartReportPlansAsync( session.ActiveReportPlanOrder.Add(plan); var coveredPoints = ResolveCoveredPoints(plan, result.CoveredReferences); - var acquisitionLabel = string.IsNullOrWhiteSpace(result.AcquisitionLabel) - ? BuildPlanAcquisitionLabel(plan, result.UsedDynamicDataSet) - : result.AcquisitionLabel; + var acquisitionLabel = session.StaticDataSetReportOnly + ? BuildStaticDataSetAcquisitionLabel(plan) + : string.IsNullOrWhiteSpace(result.AcquisitionLabel) + ? BuildPlanAcquisitionLabel(plan, result.UsedDynamicDataSet) + : result.AcquisitionLabel; foreach (var point in coveredPoints) { - if (RequiresExactMmsValueAuthority(point.IecReference)) - { - if (session.StaticDataSetReportOnly && session.States.TryGetValue(point.PointKey, out var unresolvedState)) - { - unresolvedState.SourceMode = "Static DataSet: report leaf unresolved"; - unresolvedState.AcquisitionLabel = unresolvedState.SourceMode; - unresolvedState.Reason = "structured report projection is not yet schema-proven; unsafe MMS fallback is disabled in Static DataSet mode"; - EmitStatusSnapshot(point, unresolvedState, "Static report leaf unresolved / no MMS fallback", "Pending"); - } + // Hybrid/manual mode retains its conservative exact-MMS authority rule. + // Static DataSet report-only mode is different: when ARIEC proves that + // the configured RCB covers the literal DataSet member, bind that point + // to the report plan and let schema-safe report projection decide whether + // an arriving value is publishable. Never convert this case into MMS. + if (!session.StaticDataSetReportOnly && RequiresExactMmsValueAuthority(point.IecReference)) continue; - } + session.PointPlanIds[point.PointKey] = plan.PlanId; if (!string.IsNullOrWhiteSpace(result.ReportControlReference)) point.ReportControlReference = result.ReportControlReference; @@ -768,8 +769,12 @@ private async Task StartReportPlansAsync( { pointState.AcquisitionLabel = acquisitionLabel; pointState.SourceMode = acquisitionLabel; - pointState.Reason = "awaiting report / MMS initial read"; - pointState.Status = "Report armed / verification active"; + pointState.Reason = session.StaticDataSetReportOnly + ? "awaiting configured static report / General Interrogation" + : "awaiting report / MMS initial read"; + pointState.Status = session.StaticDataSetReportOnly + ? "Static report armed / awaiting value" + : "Report armed / verification active"; } } @@ -954,22 +959,37 @@ private async Task> BuildReportPlansForCurrentA { IsAuthoritative = true, Authority = "ARIEC61850 hybrid acquisition", - Status = "Planner failure / polling safe", - Summary = $"ARIEC hybrid planning failed closed: {ex.GetType().Name}: {ex.Message}. No local RCB heuristic was substituted; bounded MMS polling remains active.", + Status = session.StaticDataSetReportOnly + ? "Planner failure / static report unavailable" + : "Planner failure / polling safe", + Summary = session.StaticDataSetReportOnly + ? $"ARIEC report planning failed closed: {ex.GetType().Name}: {ex.Message}. No local RCB heuristic was substituted and MMS process fallback remains disabled by Static DataSet mode." + : $"ARIEC hybrid planning failed closed: {ex.GetType().Name}: {ex.Message}. No local RCB heuristic was substituted; bounded MMS polling remains active.", RequestedPointCount = session.Points.Count, - PollingPointKeys = session.Points.Keys.ToArray(), - PollingFallbackSignalCount = session.Points.Count, + PollingPointKeys = session.StaticDataSetReportOnly ? Array.Empty() : session.Points.Keys.ToArray(), + PollingFallbackSignalCount = session.StaticDataSetReportOnly ? 0 : session.Points.Count, + UncoveredSignalCount = session.StaticDataSetReportOnly ? session.Points.Count : 0, Warnings = [$"Hybrid planning exception: {ex.GetType().Name}: {ex.Message}"] }; } session.HybridValidation.Reset(hybrid); - 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}"); + if (session.StaticDataSetReportOnly) + { + Log("INFO", session.Device.Name, + $"Static DataSet ARIEC authority={hybrid.Authority}; status={hybrid.Status}; requested={hybrid.RequestedPointCount}, catalog={hybrid.CatalogMappedPointCount}, staticBRCB={hybrid.StaticBrcbSignalCount}, staticURCB={hybrid.StaticUrcbSignalCount}, engineFallbackCandidates={hybrid.PollingFallbackSignalCount}, uncovered={hybrid.UncoveredSignalCount}. Engine fallback candidates are diagnostic only and are not scheduled as MMS process polling."); + } + else + { + 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); - return hybrid.ReportPlans; + return session.StaticDataSetReportOnly + ? hybrid.ReportPlans.Where(plan => !plan.AllowDynamicDataSetWrites).ToArray() + : hybrid.ReportPlans; } session.HybridValidation.Reset(null); @@ -1051,22 +1071,37 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT if (slice.ReportFrames.Count > 0 || slice.Updates.Count > 0) RecordSuccessfulIo(session); - foreach (var update in slice.Updates) + foreach (var sourceUpdate in slice.Updates) { - var point = FindPointForReportReference(session, update.Reference); - if (point == null) - continue; + var effectiveUpdates = session.StaticDataSetReportOnly + ? session.StaticReportProjection.Project( + session.Device.LiveDiscoveryModel ?? session.Device.SclWorkspace?.DesignModel, + session.Points.Values.ToArray(), + sourceUpdate) + : new[] { sourceUpdate }; + + foreach (var update in effectiveUpdates) + { + var point = FindPointForReportReference(session, update.Reference); + if (point == null) + continue; - // Siemens static DataSets report THD as a nested three-phase structure. - // Until its scalar fan-out is schema-proven, exact MMS reads remain the - // value authority so a report projection cannot overwrite correct values. - if (RequiresExactMmsValueAuthority(point.IecReference)) - continue; + // The old rule rejected every THD/DmdWh report value merely because + // those families once required MMS reads. ARIEC now provides exact + // semantic leaf identities, and StaticDataSetReportProjectionAccumulator + // reconstructs structured parents from schema-named leaves. Keep the + // legacy veto only when the report update itself is not schema-proven. + if (RequiresExactMmsValueAuthority(point.IecReference) && + !HasSchemaProvenReportValueAuthority(point, update)) + { + continue; + } - // A real report update proves reference coverage, but an initial GI or - // integrity image does not yet prove that dchg/qchg is working. Keep MMS - // verification active until a change report is actually observed. - session.PointPlanIds[point.PointKey] = plan.PlanId; + // A real report update proves reference coverage. In Hybrid mode an + // initial GI/integrity image still keeps MMS verification active until + // dchg/qchg is proven; Static DataSet mode publishes the schema-safe GI + // image directly because cyclic MMS process verification is disabled. + session.PointPlanIds[point.PointKey] = plan.PlanId; var state = session.States[point.PointKey]; var display = update.HasValue @@ -1084,7 +1119,8 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT { // A malformed/misaligned report is still useful as proof that the // RCB is alive, but its process value is not authoritative. Do not - // mutate state or SOE history; keep MMS verification/fallback active. + // mutate state or SOE history. Hybrid mode keeps MMS verification; + // Static DataSet mode stays report-pending without any MMS fallback. state.ReportTrafficSeen = true; state.LastReportUtc = DateTime.UtcNow; state.ReportChangeVerified = false; @@ -1096,7 +1132,9 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT if (rawSummary.Length > 120) rawSummary = rawSummary[..120] + "…"; Log("WARN", session.Device.Name, - $"REPORT_VALUE_REJECTED: {point.SignalName} ({point.IecReference}) rejected report value '{rawSummary}'. {rejectionReason} MMS verification/fallback remains authoritative."); + session.StaticDataSetReportOnly + ? $"REPORT_VALUE_REJECTED: {point.SignalName} ({point.IecReference}) rejected report value '{rawSummary}'. {rejectionReason} The point remains report-pending; MMS process fallback is disabled." + : $"REPORT_VALUE_REJECTED: {point.SignalName} ({point.IecReference}) rejected report value '{rawSummary}'. {rejectionReason} MMS verification/fallback remains authoritative."); } continue; } @@ -1168,11 +1206,16 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT reportSource, string.IsNullOrWhiteSpace(update.Reason) ? "report / reason not supplied" : update.Reason, receivedUtc, - state.ReportChangeVerified - ? string.IsNullOrWhiteSpace(update.ProjectionStatus) ? "Live / report verified" : $"Live / report verified ({update.ProjectionStatus})" - : string.IsNullOrWhiteSpace(update.ProjectionStatus) ? "Live / report traffic + MMS verification" : $"Live / report traffic + MMS verification ({update.ProjectionStatus})", + session.StaticDataSetReportOnly + ? state.ReportChangeVerified + ? string.IsNullOrWhiteSpace(update.ProjectionStatus) ? "Live / static report verified" : $"Live / static report verified ({update.ProjectionStatus})" + : string.IsNullOrWhiteSpace(update.ProjectionStatus) ? "Live / static report" : $"Live / static report ({update.ProjectionStatus})" + : state.ReportChangeVerified + ? string.IsNullOrWhiteSpace(update.ProjectionStatus) ? "Live / report verified" : $"Live / report verified ({update.ProjectionStatus})" + : string.IsNullOrWhiteSpace(update.ProjectionStatus) ? "Live / report traffic + MMS verification" : $"Live / report traffic + MMS verification ({update.ProjectionStatus})", trustReportEdge: true, hasProcessValue: update.HasValue); + } } var verifiedReportPointKeys = slice.Updates @@ -2032,6 +2075,7 @@ private async Task StopMonitoringSessionAsync(DeviceSession session, bool preser session.ActiveReportPlans.Clear(); session.ActiveReportPlanOrder.Clear(); session.PointPlanIds.Clear(); + session.StaticReportProjection.Reset(); session.PollQueue.Clear(); session.ReportStreams.Clear(); session.LastUnroutedReportCount = 0; @@ -2111,6 +2155,33 @@ private static bool RequiresExactMmsValueAuthority(string reference) normalized.Contains(".dmdwh", StringComparison.OrdinalIgnoreCase); } + private static bool HasSchemaProvenReportValueAuthority( + Iec61850MonitorPoint point, + NativeReportValueUpdate update) + { + var projectionStatus = (update.ProjectionStatus ?? string.Empty).Trim(); + if (projectionStatus.Equals("semantic-structured-leaf", StringComparison.OrdinalIgnoreCase) || + projectionStatus.Equals("schema-safe-report-three-phase-aggregate", StringComparison.OrdinalIgnoreCase) || + projectionStatus.Equals("schema-safe-report-demand-energy-aggregate", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // Exact report identity is also authoritative for a scalar member. This admits + // direct and deterministic MX-pair projections only when ARIEC has named exactly + // the same IEC reference selected by the runtime; parent/child prefix coincidence + // alone is not sufficient. + if (!CanonicalDataReference(point.IecReference) + .Equals(CanonicalDataReference(update.Reference), StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return projectionStatus.Length == 0 || + projectionStatus.Equals("direct", StringComparison.OrdinalIgnoreCase) || + projectionStatus.Equals("projected-mx-pair", StringComparison.OrdinalIgnoreCase); + } + private static bool HasExactSemanticEdge(Iec61850MonitorPoint point, string oldValue, string newValue) { var oldText = (oldValue ?? string.Empty).Trim(); @@ -2257,6 +2328,13 @@ private static int GetVerificationPollIntervalMs( state.ReportTrafficSeen, state.ReportChangeVerified); + private static string BuildStaticDataSetAcquisitionLabel(ReportControlPlan plan) + { + if (!string.IsNullOrWhiteSpace(plan.EngineAcquisitionKind)) + return $"Static DataSet: {plan.EngineAcquisitionKind}"; + return plan.Buffered ? "Static DataSet: StaticBrcb" : "Static DataSet: StaticUrcb"; + } + private static string BuildPlanAcquisitionLabel(ReportControlPlan plan, bool dynamicDataSet) { var reference = (plan.ReportControlReference ?? string.Empty).Trim().Replace('$', '.'); From e82f21cf8faf6ece42d03c965c75b87dfc81465c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 16:40:27 +0700 Subject: [PATCH 14/51] Document Static DataSet report-only acceptance contract --- docs/static-dataset-report-only-contract.md | 31 +++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 docs/static-dataset-report-only-contract.md diff --git a/docs/static-dataset-report-only-contract.md b/docs/static-dataset-report-only-contract.md new file mode 100644 index 000000000..d203cbd0b --- /dev/null +++ b/docs/static-dataset-report-only-contract.md @@ -0,0 +1,31 @@ +# Static DataSet report-only contract + +This document is the acceptance boundary for ARSAS Static DataSet monitoring. + +## Acquisition authority + +- The imported/live ARIEC Static DataSet membership is the signal-selection source of truth. +- One user-visible runtime row is selected for each exact DataSet membership identity; browsed aliases do not become extra rows merely because they share a DataSetReference. +- Configured static BRCB/URCB plus General Interrogation are the process-value acquisition path. +- Cyclic MMS process polling is disabled in this mode. +- Dynamic DataSet writes are disabled in this mode. +- ARIEC planner fallback candidates are diagnostic evidence only and must never become silent MMS polling. + +Manual / Select Signals mode keeps the normal Hybrid acquisition behavior and is intentionally outside this contract. + +## Structured report values + +ARIEC may decode one structured Static DataSet member into exact semantic scalar descendants. ARSAS may reconstruct the parent DataSet row only when the live/SCL schema proves every required descendant identity. This projection must fail closed: no positional phase guessing, prefix/fuzzy matching, sibling substitution, or MMS fallback is allowed. + +Examples covered by the schema-safe report projection are three-phase THD aggregates such as `ThdA` / `ThdPPV` and demand-energy aggregate values such as `DmdWhMV` when the authoritative model exposes the required scalar leaf. + +## Physical acceptance + +A Static DataSet physical test passes this contract only when: + +1. Live Signal Values contains the exact Static DataSet membership set rather than duplicate `cVal` / `instCVal` aliases. +2. The Acquisition column contains only configured static report acquisition (`StaticBrcb` / `StaticUrcb`) for resolved values; it contains no `MMS polling` rows. +3. General Interrogation can supply the initial report image for schema-safe reportable members. +4. Unsupported or ambiguous report projection remains explicitly Pending/unresolved instead of silently switching acquisition method. + +This keeps the Static DataSet workflow deterministic and prevents a readability fix from regressing the established report-only protocol contract. From 20b10439ffd84debcf0ab1996035f39b8debc047 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 20:04:26 +0700 Subject: [PATCH 15/51] Avoid per-keystroke IP wizard UI churn --- IpConnectWizardWindow.xaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/IpConnectWizardWindow.xaml b/IpConnectWizardWindow.xaml index 892f0445d..0fcc5a0f4 100644 --- a/IpConnectWizardWindow.xaml +++ b/IpConnectWizardWindow.xaml @@ -35,9 +35,9 @@ - + Text="{Binding RelayIpAddress, RelativeSource={RelativeSource AncestorType=Window}, UpdateSourceTrigger=LostFocus}"/> From b6e6b2a865fe35341f7098666f950a674d034e95 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 20:05:49 +0700 Subject: [PATCH 16/51] Preserve report q and timestamp across semantic value projection --- ...taticDataSetReportProjectionAccumulator.cs | 184 ++++++++++++++++-- 1 file changed, 166 insertions(+), 18 deletions(-) diff --git a/Services/StaticDataSetReportProjectionAccumulator.cs b/Services/StaticDataSetReportProjectionAccumulator.cs index 2ec4a2ca1..ac4e82d50 100644 --- a/Services/StaticDataSetReportProjectionAccumulator.cs +++ b/Services/StaticDataSetReportProjectionAccumulator.cs @@ -27,10 +27,27 @@ private sealed record LeafState( DateTimeOffset UpdatedAt, NativeReportValueUpdate Source); + private sealed record CompanionMetadata( + string Quality, + string Timestamp, + DateTimeOffset UpdatedAt); + private readonly Dictionary> _leavesByParent = new(StringComparer.OrdinalIgnoreCase); - public void Reset() => _leavesByParent.Clear(); + // IEC 61850 report projection may publish q/t companion evidence before the scalar + // value leaf produced by semantic expansion. Keep that report evidence session-local + // and join it back to the later value update. This is deliberately not an MMS read or + // a synthetic "good" quality default: only q/t already supplied by the report can be + // carried forward. + private readonly Dictionary _metadataByReference = + new(StringComparer.OrdinalIgnoreCase); + + public void Reset() + { + _leavesByParent.Clear(); + _metadataByReference.Clear(); + } /// /// Returns the updates that should be consumed by the Static DataSet runtime for one @@ -47,12 +64,18 @@ public IReadOnlyList Project( ArgumentNullException.ThrowIfNull(monitoredPoints); ArgumentNullException.ThrowIfNull(update); - if (!update.HasValue || string.IsNullOrWhiteSpace(update.Reference)) + if (string.IsNullOrWhiteSpace(update.Reference)) return new[] { update }; var updateReference = NormalizeReference(update.Reference); + RememberCompanionMetadata(updateReference, update); + var effectiveUpdate = EnrichFromCompanionMetadata(updateReference, update); + + if (!effectiveUpdate.HasValue) + return new[] { effectiveUpdate }; + var exactSelected = monitoredPoints.Any(point => - ReferencesEqual(point.IecReference, update.Reference)); + ReferencesEqual(point.IecReference, effectiveUpdate.Reference)); var aggregateParents = monitoredPoints .Where(point => IsAggregate(point.IecReference)) @@ -62,7 +85,7 @@ public IReadOnlyList Project( // A semantic child may not be assigned to an aggregate unless its parent is unique. // In an ambiguous model, preserve only an independently selected exact child. if (aggregateParents.Length != 1) - return exactSelected ? new[] { update } : aggregateParents.Length == 0 ? new[] { update } : Array.Empty(); + return exactSelected ? new[] { effectiveUpdate } : aggregateParents.Length == 0 ? new[] { effectiveUpdate } : Array.Empty(); var parent = aggregateParents[0]; if (!SchemaSafeAggregateProjectionService.TryBuildReadPlan( @@ -71,16 +94,16 @@ public IReadOnlyList Project( out var plan, out _)) { - return exactSelected ? new[] { update } : Array.Empty(); + return exactSelected ? new[] { effectiveUpdate } : Array.Empty(); } var expectedLeaf = plan.Leaves.FirstOrDefault(leaf => - ReferencesEqual(leaf.Reference, update.Reference)); + ReferencesEqual(leaf.Reference, effectiveUpdate.Reference)); if (expectedLeaf is null) { // Ignore non-authoritative siblings such as instCVal when cVal is the schema- // preferred engineering value. Keep it only when it is a separate selected row. - return exactSelected ? new[] { update } : Array.Empty(); + return exactSelected ? new[] { effectiveUpdate } : Array.Empty(); } var parentKey = NormalizeReference(parent.IecReference); @@ -93,22 +116,137 @@ public IReadOnlyList Project( leaves[NormalizeReference(expectedLeaf.Reference)] = new LeafState( expectedLeaf.Reference, expectedLeaf.Label, - update.Value, - update.HasQuality ? update.Quality : string.Empty, - update.HasTimestamp ? update.Timestamp : string.Empty, - update.Reason, - update.UpdatedAt, - update); + effectiveUpdate.Value, + effectiveUpdate.HasQuality ? effectiveUpdate.Quality : string.Empty, + effectiveUpdate.HasTimestamp ? effectiveUpdate.Timestamp : string.Empty, + effectiveUpdate.Reason, + effectiveUpdate.UpdatedAt, + effectiveUpdate); var projected = BuildParentUpdate(parent, plan, leaves); if (projected is null) - return exactSelected ? new[] { update } : Array.Empty(); + return exactSelected ? new[] { effectiveUpdate } : Array.Empty(); return exactSelected - ? new[] { update, projected } + ? new[] { effectiveUpdate, projected } : new[] { projected }; } + private void RememberCompanionMetadata(string updateReference, NativeReportValueUpdate update) + { + if ((!update.HasQuality || !IsUsefulMetadata(update.Quality)) && + (!update.HasTimestamp || !IsUsefulMetadata(update.Timestamp))) + { + return; + } + + foreach (var key in MetadataKeys(updateReference)) + { + _metadataByReference.TryGetValue(key, out var previous); + var quality = update.HasQuality && IsUsefulMetadata(update.Quality) + ? update.Quality.Trim() + : previous?.Quality ?? string.Empty; + var timestamp = update.HasTimestamp && IsUsefulMetadata(update.Timestamp) + ? update.Timestamp.Trim() + : previous?.Timestamp ?? string.Empty; + var updatedAt = update.UpdatedAt == default + ? previous?.UpdatedAt ?? default + : update.UpdatedAt; + + _metadataByReference[key] = new CompanionMetadata(quality, timestamp, updatedAt); + } + } + + private NativeReportValueUpdate EnrichFromCompanionMetadata( + string updateReference, + NativeReportValueUpdate update) + { + if (!update.HasValue || (update.HasQuality && update.HasTimestamp)) + return update; + + CompanionMetadata? metadata = null; + foreach (var key in MetadataKeys(updateReference)) + { + if (_metadataByReference.TryGetValue(key, out metadata)) + break; + } + + if (metadata is null) + return update; + + var quality = update.HasQuality && IsUsefulMetadata(update.Quality) + ? update.Quality + : metadata.Quality; + var timestamp = update.HasTimestamp && IsUsefulMetadata(update.Timestamp) + ? update.Timestamp + : metadata.Timestamp; + var hasQuality = update.HasQuality || IsUsefulMetadata(quality); + var hasTimestamp = update.HasTimestamp || IsUsefulMetadata(timestamp); + + if (hasQuality == update.HasQuality && + hasTimestamp == update.HasTimestamp && + string.Equals(quality, update.Quality, StringComparison.Ordinal) && + string.Equals(timestamp, update.Timestamp, StringComparison.Ordinal)) + { + return update; + } + + return new NativeReportValueUpdate + { + Reference = update.Reference, + FunctionalConstraint = update.FunctionalConstraint, + Value = update.Value, + Quality = quality, + Timestamp = timestamp, + Reason = update.Reason, + Source = update.Source, + ProjectionStatus = update.ProjectionStatus, + HasValue = update.HasValue, + HasQuality = hasQuality, + HasTimestamp = hasTimestamp, + ReportControlReference = update.ReportControlReference, + ReportId = update.ReportId, + DataSetReference = update.DataSetReference, + SequenceNumber = update.SequenceNumber, + ConfRev = update.ConfRev, + ReportTimestamp = update.ReportTimestamp, + UpdatedAt = update.UpdatedAt + }; + } + + private static IEnumerable MetadataKeys(string normalizedReference) + { + if (string.IsNullOrWhiteSpace(normalizedReference)) + yield break; + + yield return normalizedReference; + + var scope = MetadataScope(normalizedReference); + if (!scope.Equals(normalizedReference, StringComparison.OrdinalIgnoreCase)) + yield return scope; + } + + private static string MetadataScope(string normalizedReference) + { + var suffixes = new[] + { + ".instcval.mag.f", + ".cval.mag.f", + ".instmag.f", + ".mag.f", + ".q", + ".t" + }; + + foreach (var suffix in suffixes) + { + if (normalizedReference.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + return normalizedReference[..^suffix.Length]; + } + + return normalizedReference; + } + private static NativeReportValueUpdate? BuildParentUpdate( Iec61850MonitorPoint parent, SchemaSafeAggregateProjectionService.ReadPlan plan, @@ -191,8 +329,8 @@ private static NativeReportValueUpdate Synthesize( Source = "report", ProjectionStatus = projectionStatus, HasValue = true, - HasQuality = IsUseful(quality), - HasTimestamp = IsUseful(timestamp), + HasQuality = IsUsefulMetadata(quality), + HasTimestamp = IsUsefulMetadata(timestamp), ReportControlReference = latest.Source.ReportControlReference, ReportId = latest.Source.ReportId, DataSetReference = latest.Source.DataSetReference, @@ -206,7 +344,7 @@ private static NativeReportValueUpdate Synthesize( private static string SharedUseful(IEnumerable values) { var useful = values - .Where(IsUseful) + .Where(IsUsefulMetadata) .Select(value => value.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -248,4 +386,14 @@ private static bool TryParseNumeric(string? value, out double numeric) private static bool IsUseful(string? value) => !string.IsNullOrWhiteSpace(value) && value != "-"; + + private static bool IsUsefulMetadata(string? value) + { + if (!IsUseful(value)) + return false; + + var normalized = value!.Trim(); + return !normalized.Equals("Unknown", StringComparison.OrdinalIgnoreCase) && + !normalized.StartsWith("Pending", StringComparison.OrdinalIgnoreCase); + } } From 39a91b3d18981dac2c9c273ee67b2d149023b30f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 20:06:35 +0700 Subject: [PATCH 17/51] Add regressions for report q/t carry-forward and IP editing responsiveness --- ...DataSetReportProjectionAccumulatorTests.cs | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/tests/ARSAS.Tests/StaticDataSetReportProjectionAccumulatorTests.cs b/tests/ARSAS.Tests/StaticDataSetReportProjectionAccumulatorTests.cs index 3ebdd0261..474b5c545 100644 --- a/tests/ARSAS.Tests/StaticDataSetReportProjectionAccumulatorTests.cs +++ b/tests/ARSAS.Tests/StaticDataSetReportProjectionAccumulatorTests.cs @@ -37,6 +37,48 @@ public void ThdA_SemanticLeaves_ReconstructOneStaticParentValue() Assert.True(projected.HasTimestamp); } + [Fact] + public void ReportCompanionQualityAndTimestamp_ArrivingBeforeSemanticValue_ArePreserved() + { + const string parent = "IEDLD0/I_MHAI1.ThdA"; + const string phaseA = parent + ".phsA.cVal.mag.f"; + const string phaseB = parent + ".phsB.cVal.mag.f"; + const string phaseC = parent + ".phsC.cVal.mag.f"; + var model = Model(DataObject( + parent, + "ThdA", + Attribute(phaseA, "phsA.cVal.mag.f"), + Attribute(phaseB, "phsB.cVal.mag.f"), + Attribute(phaseC, "phsC.cVal.mag.f"))); + var parentPoint = Point(parent); + var phaseAPoint = Point(phaseA); + var monitored = new[] { parentPoint, phaseAPoint }; + var accumulator = new StaticDataSetReportProjectionAccumulator(); + + // ARIEC may expose q/t on the phase structure before its semantic cVal.mag.f leaf. + accumulator.Project(model, monitored, CompanionUpdate(parent + ".phsA")); + accumulator.Project(model, monitored, CompanionUpdate(parent + ".phsB")); + accumulator.Project(model, monitored, CompanionUpdate(parent + ".phsC")); + + var phaseAUpdates = accumulator.Project(model, monitored, ValueOnlyUpdate(phaseA, "1")); + var exactPhaseA = Assert.Single(phaseAUpdates, value => + value.Reference.Equals(phaseA, StringComparison.OrdinalIgnoreCase)); + Assert.Equal("questionable", exactPhaseA.Quality, ignoreCase: true); + Assert.Equal("2026-09-04 16:20:51.480", exactPhaseA.Timestamp); + Assert.True(exactPhaseA.HasQuality); + Assert.True(exactPhaseA.HasTimestamp); + + Assert.Empty(accumulator.Project(model, monitored, ValueOnlyUpdate(phaseB, "2"))); + var finalUpdates = accumulator.Project(model, monitored, ValueOnlyUpdate(phaseC, "3")); + var aggregate = Assert.Single(finalUpdates, value => + value.Reference.Equals(parent, StringComparison.OrdinalIgnoreCase)); + Assert.Equal("A=1, B=2, C=3", aggregate.Value); + Assert.Equal("questionable", aggregate.Quality, ignoreCase: true); + Assert.Equal("2026-09-04 16:20:51.480", aggregate.Timestamp); + Assert.True(aggregate.HasQuality); + Assert.True(aggregate.HasTimestamp); + } + [Fact] public void ThdA_InstantaneousSibling_DoesNotOverrideCanonicalCValPlan() { @@ -118,6 +160,16 @@ public void RuntimeSource_StaticMode_UsesSchemaSafeReportAuthority_AndNoMmsFallb Assert.DoesNotContain("structured report projection is not yet schema-proven; unsafe MMS fallback is disabled", source, StringComparison.Ordinal); } + [Fact] + public void IpWizard_DoesNotRunEditableAddressBindingOnEveryKeystroke() + { + var xaml = File.ReadAllText(FindRepoFile("IpConnectWizardWindow.xaml")); + + Assert.Contains("IsTextSearchEnabled=\"False\"", xaml, StringComparison.Ordinal); + Assert.Contains("RelayIpAddress, RelativeSource={RelativeSource AncestorType=Window}, UpdateSourceTrigger=LostFocus", xaml, StringComparison.Ordinal); + Assert.DoesNotContain("RelayIpAddress, RelativeSource={RelativeSource AncestorType=Window}, UpdateSourceTrigger=PropertyChanged", xaml, StringComparison.Ordinal); + } + private static NativeReportValueUpdate Update(string reference, string value) => new() { @@ -136,6 +188,42 @@ private static NativeReportValueUpdate Update(string reference, string value) UpdatedAt = new DateTimeOffset(2026, 9, 4, 16, 0, 0, TimeSpan.FromHours(7)) }; + private static NativeReportValueUpdate CompanionUpdate(string reference) + => new() + { + Reference = reference, + FunctionalConstraint = "MX", + Value = "-", + Quality = "questionable", + Timestamp = "2026-09-04 16:20:51.480", + Reason = "integrity", + ProjectionStatus = "measurement-companion", + HasValue = false, + HasQuality = true, + HasTimestamp = true, + DataSetReference = "IEDLD0/LLN0.Analog", + ReportControlReference = "IEDLD0/LLN0.RP.Unbuffer02", + UpdatedAt = new DateTimeOffset(2026, 9, 4, 16, 20, 51, 480, TimeSpan.FromHours(7)) + }; + + private static NativeReportValueUpdate ValueOnlyUpdate(string reference, string value) + => new() + { + Reference = reference, + FunctionalConstraint = "MX", + Value = value, + Quality = string.Empty, + Timestamp = string.Empty, + Reason = "integrity", + ProjectionStatus = "semantic-structured-leaf", + HasValue = true, + HasQuality = false, + HasTimestamp = false, + DataSetReference = "IEDLD0/LLN0.Analog", + ReportControlReference = "IEDLD0/LLN0.RP.Unbuffer02", + UpdatedAt = new DateTimeOffset(2026, 9, 4, 16, 20, 51, 481, TimeSpan.FromHours(7)) + }; + private static Iec61850MonitorPoint Point(string reference) => new() { From 747df485055f4fe196415b4523195719b29f4b06 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 20:12:54 +0700 Subject: [PATCH 18/51] Pin ARSAS to exact semantic report schema fix --- engines/ARIEC61850.lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 2e101141b..5b2e78dfb 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, "repository": "masarray/ARIEC61850", - "ref": "main", - "commit": "b9ee5fc9650b72e69bc81287a1e1b047ad19b054", - "sourcePullRequest": 106, - "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam." + "ref": "fix/semantic-report-schema-authority", + "commit": "52c19088e215f2dc482db6550367beb1dcb216bb", + "sourcePullRequest": 111, + "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is now attempted before generic structured-value heuristics so TotPF and similar members can publish their exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values so ARSAS can preserve relay-native quality/timestamp without MMS polling." } From b7a06fa81b16552342f207884c2aaee7a10672d7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 20:16:51 +0700 Subject: [PATCH 19/51] Advance pinned semantic report engine test fix --- engines/ARIEC61850.lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 5b2e78dfb..9d25e944e 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "fix/semantic-report-schema-authority", - "commit": "52c19088e215f2dc482db6550367beb1dcb216bb", + "commit": "69bfe70e2c779c7e8268af087bd1a3a38986c0fc", "sourcePullRequest": 111, "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is now attempted before generic structured-value heuristics so TotPF and similar members can publish their exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values so ARSAS can preserve relay-native quality/timestamp without MMS polling." } From b6f9ddd87107d5913de96a61718385272612ba6b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 20:18:57 +0700 Subject: [PATCH 20/51] Keep immutable engine lock metadata compatible with CI --- engines/ARIEC61850.lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 9d25e944e..5d8014d0b 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "repository": "masarray/ARIEC61850", - "ref": "fix/semantic-report-schema-authority", + "ref": "main", "commit": "69bfe70e2c779c7e8268af087bd1a3a38986c0fc", "sourcePullRequest": 111, "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is now attempted before generic structured-value heuristics so TotPF and similar members can publish their exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values so ARSAS can preserve relay-native quality/timestamp without MMS polling." From 9379a164c142a333be84bd7f60a3af89aa5b713b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 21:38:18 +0700 Subject: [PATCH 21/51] Make SNTP a global ARSAS server toggle --- MainWindow.ClockSyncToggle.cs | 220 +++++++++++++++++++++++++++++++++- 1 file changed, 214 insertions(+), 6 deletions(-) diff --git a/MainWindow.ClockSyncToggle.cs b/MainWindow.ClockSyncToggle.cs index d9ed36b99..39598e3b3 100644 --- a/MainWindow.ClockSyncToggle.cs +++ b/MainWindow.ClockSyncToggle.cs @@ -1,19 +1,115 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Media; +using System.Windows.Shapes; using ArIED61850Tester.Models; +using ArIED61850Tester.Services; namespace ArIED61850Tester; public partial class MainWindow { + // Keep the existing commissioning default for compatibility, but make the state explicit + // in the global ARSAS chrome. The service is application-scoped: FAT never owns it. private bool _clockSyncEnabled = true; + private ToggleButton? _globalSntpToggle; + private Ellipse? _globalSntpStateDot; + private TextBlock? _globalSntpCaption; + private bool _globalSntpToggleRefreshing; internal bool IsClockSyncEnabled => _clockSyncEnabled; + private void InstallGlobalSntpToggle() + { + if (_globalSntpToggle != null || WorkflowNavShell.Parent is not Grid headerGrid) + return; + + var stateDot = new Ellipse + { + Width = 8, + Height = 8, + Margin = new Thickness(0, 0, 8, 0), + VerticalAlignment = VerticalAlignment.Center, + Fill = Brushes.SlateGray + }; + var caption = new TextBlock + { + Text = "SNTP Server", + FontSize = 11.3, + FontWeight = FontWeights.SemiBold, + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis + }; + var content = new StackPanel + { + Orientation = Orientation.Horizontal, + VerticalAlignment = VerticalAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center + }; + content.Children.Add(stateDot); + content.Children.Add(caption); + + var toggle = new ToggleButton + { + Name = "GlobalSntpServerToggle", + MinWidth = 282, + MaxWidth = 340, + Height = 38, + Margin = new Thickness(12, 0, 0, 0), + Padding = new Thickness(12, 0, 12, 0), + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Center, + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center, + BorderThickness = new Thickness(1), + Background = new SolidColorBrush(Color.FromRgb(244, 247, 251)), + BorderBrush = new SolidColorBrush(Color.FromRgb(205, 217, 234)), + Foreground = new SolidColorBrush(Color.FromRgb(82, 103, 126)), + Focusable = false, + IsThreeState = false, + Content = content + }; + + toggle.Checked += GlobalSntpToggle_Changed; + toggle.Unchecked += GlobalSntpToggle_Changed; + Grid.SetColumn(toggle, 2); + headerGrid.Children.Add(toggle); + + _globalSntpToggle = toggle; + _globalSntpStateDot = stateDot; + _globalSntpCaption = caption; + RefreshGlobalSntpToggle(_sntpClockService.Snapshot); + } + + private async void GlobalSntpToggle_Changed(object sender, RoutedEventArgs e) + { + if (_globalSntpToggleRefreshing || _globalSntpToggle == null) + return; + + _globalSntpToggle.IsEnabled = false; + try + { + await SetClockSyncEnabledAsync(_globalSntpToggle.IsChecked == true); + } + finally + { + _globalSntpToggle.IsEnabled = true; + } + } + internal async Task SetClockSyncEnabledAsync(bool enabled) { if (_clockSyncEnabled == enabled) + { + if (enabled) + _sntpClockService.RequestImmediateBroadcast(); + PublishGlobalSntpUiState(); return; + } _clockSyncEnabled = enabled; + PublishGlobalSntpUiState(); if (!enabled) { @@ -27,19 +123,20 @@ internal async Task SetClockSyncEnabledAsync(bool enabled) await _sntpClockService.StopAsync(); _clockSyncObservedClients.Clear(); _clockSyncRepliedClients.Clear(); - AddLog("INFO", "Clock Sync", - "Clock Sync disabled from the FAT workspace. IEC 61850 monitoring remains active."); + AddLog("INFO", "SNTP Server", + "Global SNTP Server disabled. IEC 61850 monitoring and FAT sessions remain active."); } catch (Exception ex) { - AddLog("WARN", "Clock Sync", - $"Clock Sync disable requested, but the SNTP service reported: {ex.Message}"); + AddLog("WARN", "SNTP Server", + $"Global SNTP Server disable requested, but the clock service reported: {ex.Message}"); } finally { _clockSyncIntegrationGate.Release(); } + PublishGlobalSntpUiState(); return; } @@ -48,7 +145,118 @@ internal async Task SetClockSyncEnabledAsync(bool enabled) foreach (var device in Devices) AttachClockSyncDevice(device); - AddLog("INFO", "Clock Sync", - "Clock Sync enabled from the FAT workspace. ARSAS will serve connected IPv4 IEDs using normal UDP/123 when available and an Npcap RAW fallback when Windows already owns that port. Windows Time is never stopped or reconfigured."); + AddLog("INFO", "SNTP Server", + "Global SNTP Server enabled. It remains active independently of FAT while ARSAS is running. Connected IPv4 IEDs select the station-bus interface; ARSAS serves UDP/123 and broadcasts Mode 5 time, with Npcap RAW fallback when Windows already owns UDP/123. Windows Time is never stopped or reconfigured."); + PublishGlobalSntpUiState(); + } + + private void PublishGlobalSntpUiState() + { + var snapshot = _sntpClockService.Snapshot; + + void Publish() + { + RefreshGlobalSntpToggle(snapshot); + ClockSyncSnapshotChanged?.Invoke(snapshot); + } + + if (Dispatcher.CheckAccess()) + Publish(); + else + Dispatcher.BeginInvoke(new Action(Publish)); + } + + private void RefreshGlobalSntpToggle(SntpClockServiceSnapshot snapshot) + { + if (_globalSntpToggle == null || _globalSntpCaption == null || _globalSntpStateDot == null) + return; + + _globalSntpToggleRefreshing = true; + try + { + _globalSntpToggle.IsChecked = _clockSyncEnabled; + } + finally + { + _globalSntpToggleRefreshing = false; + } + + var localAddress = snapshot.Binding?.LocalAddress.ToString(); + var isServing = _clockSyncEnabled && snapshot.State == SntpClockServiceState.Serving; + var isStarting = _clockSyncEnabled && snapshot.State == SntpClockServiceState.Starting; + var isFault = _clockSyncEnabled && snapshot.State is SntpClockServiceState.Faulted or SntpClockServiceState.PortUnavailable; + + _globalSntpCaption.Text = !_clockSyncEnabled + ? "SNTP Server Off" + : isServing + ? $"SNTP Server Active: {localAddress ?? "station bus"}" + : isStarting + ? "SNTP Server Starting…" + : isFault + ? "SNTP Server Attention" + : "SNTP Server Enabled · waiting for IED"; + + if (isServing) + { + _globalSntpToggle.Background = new SolidColorBrush(Color.FromRgb(236, 253, 245)); + _globalSntpToggle.BorderBrush = new SolidColorBrush(Color.FromRgb(167, 243, 208)); + _globalSntpToggle.Foreground = new SolidColorBrush(Color.FromRgb(4, 120, 87)); + _globalSntpStateDot.Fill = new SolidColorBrush(Color.FromRgb(16, 185, 129)); + } + else if (isFault) + { + _globalSntpToggle.Background = new SolidColorBrush(Color.FromRgb(255, 248, 230)); + _globalSntpToggle.BorderBrush = new SolidColorBrush(Color.FromRgb(242, 210, 138)); + _globalSntpToggle.Foreground = new SolidColorBrush(Color.FromRgb(148, 98, 0)); + _globalSntpStateDot.Fill = new SolidColorBrush(Color.FromRgb(245, 158, 11)); + } + else if (_clockSyncEnabled) + { + _globalSntpToggle.Background = new SolidColorBrush(Color.FromRgb(238, 244, 255)); + _globalSntpToggle.BorderBrush = new SolidColorBrush(Color.FromRgb(201, 217, 241)); + _globalSntpToggle.Foreground = new SolidColorBrush(Color.FromRgb(69, 100, 142)); + _globalSntpStateDot.Fill = new SolidColorBrush(Color.FromRgb(47, 128, 237)); + } + else + { + _globalSntpToggle.Background = new SolidColorBrush(Color.FromRgb(244, 247, 251)); + _globalSntpToggle.BorderBrush = new SolidColorBrush(Color.FromRgb(205, 217, 234)); + _globalSntpToggle.Foreground = new SolidColorBrush(Color.FromRgb(82, 103, 126)); + _globalSntpStateDot.Fill = new SolidColorBrush(Color.FromRgb(148, 163, 184)); + } + + _globalSntpToggle.ToolTip = BuildGlobalSntpToolTip(snapshot); + } + + private string BuildGlobalSntpToolTip(SntpClockServiceSnapshot snapshot) + { + var binding = snapshot.Binding; + var local = binding?.LocalAddress.ToString() ?? "waiting for first connected IPv4 IED"; + var broadcast = binding?.DirectedBroadcast?.ToString() ?? "—"; + var adapter = binding?.InterfaceName ?? "—"; + var transport = snapshot.TransportMode switch + { + SntpClockTransportMode.UdpSocket => "UDP/123", + SntpClockTransportMode.NpcapRaw => "Npcap RAW", + _ => "—" + }; + var lastBroadcast = snapshot.LastBroadcastUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss.fff") ?? "—"; + var lastRequest = snapshot.LastRequestUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss.fff") ?? "—"; + var lastReply = snapshot.LastReplyUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss.fff") ?? "—"; + + return $"Global ARSAS SNTP Server\n" + + $"Enabled: {_clockSyncEnabled}\n" + + $"State: {snapshot.State}\n" + + $"Local server IP: {local}\n" + + $"Station-bus adapter: {adapter}\n" + + $"Directed broadcast: {broadcast}\n" + + $"Transport: {transport}\n" + + $"Mode 5 broadcasts: {snapshot.BroadcastCount} (last {lastBroadcast})\n" + + $"Client requests observed: {snapshot.ClientRequestCount} (last {lastRequest})\n" + + $"Mode 4 replies sent: {snapshot.ReplyCount} (last {lastReply})\n\n" + + "When enabled, this service is global and continues outside FAT until disabled or ARSAS closes. " + + "A sent broadcast or reply proves SNTP packet activity, not that an IED accepted or locked its internal clock. " + + "Relay/event timestamps are trustworthy only after device-side synchronization evidence confirms the clock.\n\n" + + snapshot.Detail; } } From 7485d56c9dfc92d1d941f9e9996bce02f4b83650 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 21:38:45 +0700 Subject: [PATCH 22/51] Run SNTP lifecycle globally across connected IEDs --- MainWindow.ClockSync.cs | 54 +++++++++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/MainWindow.ClockSync.cs b/MainWindow.ClockSync.cs index aff2da34c..af2c2a58d 100644 --- a/MainWindow.ClockSync.cs +++ b/MainWindow.ClockSync.cs @@ -24,18 +24,27 @@ private void InitializeClockSyncLifecycle() return; _clockSyncLifecycleAttached = true; - Devices.CollectionChanged += ClockSyncDevices_CollectionChanged; - foreach (var device in Devices) - AttachClockSyncDevice(device); + InstallGlobalSntpToggle(); + + if (_clockSyncEnabled) + { + Devices.CollectionChanged += ClockSyncDevices_CollectionChanged; + foreach (var device in Devices) + AttachClockSyncDevice(device); + } _sntpClockService.StatusChanged += ClockSyncService_StatusChanged; _sntpClockService.ClientRequestObserved += ClockSyncService_ClientRequestObserved; _sntpClockService.ReplySent += ClockSyncService_ReplySent; Closed += ClockSyncMainWindow_Closed; + PublishGlobalSntpUiState(); } private void ClockSyncDevices_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { + if (!_clockSyncEnabled) + return; + if (e.OldItems != null) { foreach (var item in e.OldItems.OfType()) @@ -51,6 +60,9 @@ private void ClockSyncDevices_CollectionChanged(object? sender, NotifyCollection private void AttachClockSyncDevice(Iec61850MonitorDevice device) { + if (!_clockSyncEnabled) + return; + device.PropertyChanged -= ClockSyncDevice_PropertyChanged; device.PropertyChanged += ClockSyncDevice_PropertyChanged; @@ -60,16 +72,21 @@ private void AttachClockSyncDevice(Iec61850MonitorDevice device) private void ClockSyncDevice_PropertyChanged(object? sender, PropertyChangedEventArgs e) { - if (e.PropertyName != nameof(Iec61850MonitorDevice.IsConnected) || - sender is not Iec61850MonitorDevice device || - !device.IsConnected) + if (!_clockSyncEnabled || sender is not Iec61850MonitorDevice device || !device.IsConnected) return; - ScheduleClockSyncReconcile(device); + if (e.PropertyName == nameof(Iec61850MonitorDevice.IsConnected) || + e.PropertyName == nameof(Iec61850MonitorDevice.IpAddress)) + { + ScheduleClockSyncReconcile(device); + } } private void ScheduleClockSyncReconcile(Iec61850MonitorDevice device) { + if (!_clockSyncEnabled) + return; + if (!Dispatcher.CheckAccess()) { Dispatcher.BeginInvoke(new Action(() => ScheduleClockSyncReconcile(device))); @@ -81,13 +98,19 @@ private void ScheduleClockSyncReconcile(Iec61850MonitorDevice device) private async Task EnsureClockSyncForDeviceAsync(Iec61850MonitorDevice device) { - if (!IPAddress.TryParse(device.IpAddress, out var iedAddress) || + if (!_clockSyncEnabled || !device.IsConnected || + !IPAddress.TryParse(device.IpAddress, out var iedAddress) || iedAddress.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) return; await _clockSyncIntegrationGate.WaitAsync(); try { + // Re-check after entering the integration gate so a queued connect event cannot + // restart SNTP after the operator has switched the global toggle off. + if (!_clockSyncEnabled || !device.IsConnected) + return; + await _sntpClockService.EnsureStartedAsync(iedAddress, _applicationCancellation.Token); _sntpClockService.RequestImmediateBroadcast(); } @@ -96,8 +119,8 @@ private async Task EnsureClockSyncForDeviceAsync(Iec61850MonitorDevice device) } catch (Exception ex) { - AddLog("WARN", "Clock Sync", - $"{device.Name}: IEC 61850 remains connected, but ARSAS SNTP could not start: {ex.Message}"); + AddLog("WARN", "SNTP Server", + $"{device.Name}: IEC 61850 remains connected, but the global ARSAS SNTP Server could not start: {ex.Message}"); } finally { @@ -109,8 +132,9 @@ private void ClockSyncService_StatusChanged(SntpClockServiceSnapshot snapshot) { void Publish() { - // FAT telemetry must receive every evidence-counter change even when the textual - // service detail did not change. The live log remains deduplicated separately. + // Global telemetry receives every evidence-counter change even when the textual + // service detail did not change. FAT is only one passive consumer of this state. + RefreshGlobalSntpToggle(snapshot); ClockSyncSnapshotChanged?.Invoke(snapshot); var status = $"{snapshot.State}|{snapshot.TransportMode}|{snapshot.Detail}"; @@ -125,7 +149,7 @@ void Publish() SntpClockServiceState.Stopped => "INFO", _ => "WARN" }; - AddLog(level, "Clock Sync", snapshot.Detail); + AddLog(level, "SNTP Server", snapshot.Detail); } if (Dispatcher.CheckAccess()) @@ -146,7 +170,7 @@ void Publish() var device = Devices.FirstOrDefault(item => item.IpAddress.Equals(key, StringComparison.OrdinalIgnoreCase)); var name = device?.Name ?? key; - AddLog("INFO", "Clock Sync", + AddLog("INFO", "SNTP Server", $"{name} ({key}) sent an SNTPv{observation.Version} client request to ARSAS. Request observed; synchronization is not yet proven."); } @@ -169,7 +193,7 @@ void Publish() item.IpAddress.Equals(key, StringComparison.OrdinalIgnoreCase)); var name = device?.Name ?? key; var transport = observation.TransportMode == SntpClockTransportMode.NpcapRaw ? "Npcap RAW" : "UDP"; - AddLog("INFO", "Clock Sync", + AddLog("INFO", "SNTP Server", $"{name} ({key}) received an ARSAS SNTP Mode 4 reply via {transport}. Reply sent; relay clock synchronization remains unproven until device evidence confirms it."); } From 86a63555e1af30c75d6e8c8424f6f25c2442c9d8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 21:39:12 +0700 Subject: [PATCH 23/51] Make FAT SNTP status passive under global server --- IoListTestingWindow.ClockSyncUx.cs | 110 +++++++++++------------------ 1 file changed, 42 insertions(+), 68 deletions(-) diff --git a/IoListTestingWindow.ClockSyncUx.cs b/IoListTestingWindow.ClockSyncUx.cs index 9a22b81fe..2ca060789 100644 --- a/IoListTestingWindow.ClockSyncUx.cs +++ b/IoListTestingWindow.ClockSyncUx.cs @@ -7,10 +7,9 @@ namespace ArIED61850Tester; public partial class IoListTestingWindow { - private CheckBox? _clockSyncCheckBox; + private TextBlock? _clockSyncGlobalStatusText; private TextBlock? _clockSyncEvidenceText; private MainWindow? _clockSyncSnapshotOwner; - private bool _clockSyncCheckBoxRefreshing; protected override void OnInitialized(EventArgs e) { @@ -21,9 +20,8 @@ protected override void OnInitialized(EventArgs e) private void ClockSyncUx_Loaded(object sender, RoutedEventArgs e) { - if (_clockSyncCheckBox != null) + if (_clockSyncGlobalStatusText != null) { - RefreshClockSyncCheckBox(); AttachClockSyncSnapshotOwner(); return; } @@ -35,18 +33,17 @@ private void ClockSyncUx_Loaded(object sender, RoutedEventArgs e) if (previewIndex < 0) return; - var checkBox = new CheckBox + var globalStatus = new TextBlock { - Name = "ClockSyncEnabledCheckBox", - Content = "Clock Sync", + Name = "GlobalSntpStatusTextBlock", VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0), Padding = new Thickness(2, 0, 2, 0), FontSize = 11.2, FontWeight = FontWeights.SemiBold, Foreground = TryFindResource("Ink") as Brush ?? Brushes.DimGray, - Focusable = false, - ToolTip = "SNTP laptop → IED. Checked: ARSAS serves laptop time using normal UDP/123 when available, with an Npcap RAW fallback when Windows already owns UDP/123. Unchecked: ARSAS stops only its Clock Sync service. IEC 61850 remains unaffected." + Text = "Global SNTP", + ToolTip = "Global SNTP is controlled from the ARSAS header. It is not owned by FAT and continues running when the FAT window closes while the global server toggle remains enabled." }; var evidence = new TextBlock @@ -57,15 +54,12 @@ private void ClockSyncUx_Loaded(object sender, RoutedEventArgs e) FontSize = 10.4, FontWeight = FontWeights.Medium, Foreground = TryFindResource("MutedInk") as Brush ?? Brushes.SlateGray, - Text = "Clock: waiting" + Text = "SNTP: waiting" }; - _clockSyncCheckBox = checkBox; + _clockSyncGlobalStatusText = globalStatus; _clockSyncEvidenceText = evidence; - RefreshClockSyncCheckBox(); - checkBox.Checked += ClockSyncCheckBox_Changed; - checkBox.Unchecked += ClockSyncCheckBox_Changed; - actionPanel.Children.Insert(previewIndex + 1, checkBox); + actionPanel.Children.Insert(previewIndex + 1, globalStatus); actionPanel.Children.Insert(previewIndex + 2, evidence); AttachClockSyncSnapshotOwner(); } @@ -100,28 +94,40 @@ private void ClockSyncSnapshotChanged(SntpClockServiceSnapshot snapshot) private void RefreshClockSyncEvidence(SntpClockServiceSnapshot snapshot) { - if (_clockSyncEvidenceText == null) + if (_clockSyncEvidenceText == null || _clockSyncGlobalStatusText == null) return; + var enabled = _clockSyncSnapshotOwner?.IsClockSyncEnabled == true; var transport = snapshot.TransportMode switch { SntpClockTransportMode.NpcapRaw => "RAW", SntpClockTransportMode.UdpSocket => "UDP", _ => "—" }; - - _clockSyncEvidenceText.Text = snapshot.State switch - { - SntpClockServiceState.Serving => - $"{transport} · B {snapshot.BroadcastCount} · Req {snapshot.ClientRequestCount} · Reply {snapshot.ReplyCount} · sync not proven", - SntpClockServiceState.Starting => "Clock: starting…", - SntpClockServiceState.Stopped => "Clock: off", - SntpClockServiceState.PortUnavailable => "Clock: unavailable", - SntpClockServiceState.Faulted => "Clock: fault", - _ => $"Clock: {snapshot.State}" - }; - - _clockSyncEvidenceText.ToolTip = BuildClockSyncEvidenceToolTip(snapshot, transport); + var localAddress = snapshot.Binding?.LocalAddress.ToString(); + + _clockSyncGlobalStatusText.Text = !enabled + ? "Global SNTP · Off" + : snapshot.State == SntpClockServiceState.Serving + ? $"Global SNTP · {localAddress ?? "Active"}" + : "Global SNTP · Enabled"; + + _clockSyncEvidenceText.Text = !enabled + ? "SNTP: off" + : snapshot.State switch + { + SntpClockServiceState.Serving => + $"{transport} · B {snapshot.BroadcastCount} · Req {snapshot.ClientRequestCount} · Reply {snapshot.ReplyCount} · sync not proven", + SntpClockServiceState.Starting => "SNTP: starting…", + SntpClockServiceState.Stopped => "SNTP: waiting for connected IED", + SntpClockServiceState.PortUnavailable => "SNTP: unavailable", + SntpClockServiceState.Faulted => "SNTP: fault", + _ => $"SNTP: {snapshot.State}" + }; + + var toolTip = BuildClockSyncEvidenceToolTip(snapshot, transport, enabled); + _clockSyncGlobalStatusText.ToolTip = toolTip; + _clockSyncEvidenceText.ToolTip = toolTip; _clockSyncEvidenceText.Foreground = snapshot.State switch { SntpClockServiceState.PortUnavailable or SntpClockServiceState.Faulted => Brushes.DarkOrange, @@ -130,60 +136,28 @@ private void RefreshClockSyncEvidence(SntpClockServiceSnapshot snapshot) }; } - private static string BuildClockSyncEvidenceToolTip(SntpClockServiceSnapshot snapshot, string transport) + private static string BuildClockSyncEvidenceToolTip( + SntpClockServiceSnapshot snapshot, + string transport, + bool enabled) { var binding = snapshot.Binding == null ? "—" : snapshot.Binding.Summary; var lastBroadcast = snapshot.LastBroadcastUtc?.ToLocalTime().ToString("HH:mm:ss.fff") ?? "—"; var lastRequest = snapshot.LastRequestUtc?.ToLocalTime().ToString("HH:mm:ss.fff") ?? "—"; var lastReply = snapshot.LastReplyUtc?.ToLocalTime().ToString("HH:mm:ss.fff") ?? "—"; - return $"Clock Sync evidence\n" + + return $"Global ARSAS SNTP Server\n" + + $"Enabled: {enabled}\n" + $"State: {snapshot.State}\n" + $"Transport: {transport}\n" + $"Binding: {binding}\n" + $"Broadcast sent: {snapshot.BroadcastCount} (last {lastBroadcast})\n" + $"Client request seen: {snapshot.ClientRequestCount} (last {lastRequest})\n" + $"Mode 4 reply sent: {snapshot.ReplyCount} (last {lastReply})\n\n" + + "The global server is controlled from the ARSAS header and continues outside FAT while enabled. " + "These counters prove packet activity only. ARSAS does not claim that the relay clock is synchronized without device-side evidence.\n\n" + snapshot.Detail; } - private void RefreshClockSyncCheckBox() - { - if (_clockSyncCheckBox == null || Owner is not MainWindow mainWindow) - return; - - _clockSyncCheckBoxRefreshing = true; - try - { - _clockSyncCheckBox.IsChecked = mainWindow.IsClockSyncEnabled; - } - finally - { - _clockSyncCheckBoxRefreshing = false; - } - } - - private async void ClockSyncCheckBox_Changed(object sender, RoutedEventArgs e) - { - if (_clockSyncCheckBoxRefreshing || - _clockSyncCheckBox == null || - Owner is not MainWindow mainWindow) - return; - - var requested = _clockSyncCheckBox.IsChecked == true; - _clockSyncCheckBox.IsEnabled = false; - try - { - await mainWindow.SetClockSyncEnabledAsync(requested); - RefreshClockSyncCheckBox(); - RefreshClockSyncEvidence(mainWindow.ClockSyncSnapshot); - } - finally - { - _clockSyncCheckBox.IsEnabled = true; - } - } - private void ClockSyncUx_Closed(object? sender, EventArgs e) { if (_clockSyncSnapshotOwner != null) From e94e50d0ed690472a5f479d4b03a17ce27c36429 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 21:39:43 +0700 Subject: [PATCH 24/51] Guard global SNTP lifecycle and UI contract --- .../GlobalSntpLifecycleRegressionTests.cs | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 tests/ARSAS.Tests/GlobalSntpLifecycleRegressionTests.cs diff --git a/tests/ARSAS.Tests/GlobalSntpLifecycleRegressionTests.cs b/tests/ARSAS.Tests/GlobalSntpLifecycleRegressionTests.cs new file mode 100644 index 000000000..eb7b3650e --- /dev/null +++ b/tests/ARSAS.Tests/GlobalSntpLifecycleRegressionTests.cs @@ -0,0 +1,70 @@ +namespace ARSAS.Tests; + +public sealed class GlobalSntpLifecycleRegressionTests +{ + [Fact] + public void MainHeader_ExposesGlobalSntpToggleWithActualBoundServerIp() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.ClockSyncToggle.cs")); + + Assert.Contains("WorkflowNavShell.Parent is not Grid headerGrid", source, StringComparison.Ordinal); + Assert.Contains("Name = \"GlobalSntpServerToggle\"", source, StringComparison.Ordinal); + Assert.Contains("Grid.SetColumn(toggle, 2)", source, StringComparison.Ordinal); + Assert.Contains("SNTP Server Active:", source, StringComparison.Ordinal); + Assert.Contains("snapshot.Binding?.LocalAddress", source, StringComparison.Ordinal); + Assert.Contains("Directed broadcast", source, StringComparison.Ordinal); + Assert.Contains("continues outside FAT", source, StringComparison.Ordinal); + } + + [Fact] + public void GlobalLifecycle_FollowsConnectedIedsAndCannotRestartAfterToggleOff() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.ClockSync.cs")); + + Assert.Contains("Devices.CollectionChanged += ClockSyncDevices_CollectionChanged", source, StringComparison.Ordinal); + Assert.Contains("device.PropertyChanged += ClockSyncDevice_PropertyChanged", source, StringComparison.Ordinal); + Assert.Contains("EnsureStartedAsync(iedAddress", source, StringComparison.Ordinal); + Assert.Contains("if (!_clockSyncEnabled || !device.IsConnected)", source, StringComparison.Ordinal); + Assert.Contains("Closed += ClockSyncMainWindow_Closed", source, StringComparison.Ordinal); + Assert.Contains("await _sntpClockService.DisposeAsync()", source, StringComparison.Ordinal); + } + + [Fact] + public void FatWorkspace_IsPassiveConsumerOfGlobalSntpState() + { + var source = File.ReadAllText(FindRepoFile("IoListTestingWindow.ClockSyncUx.cs")); + + Assert.Contains("Global SNTP is controlled from the ARSAS header", source, StringComparison.Ordinal); + Assert.Contains("continues running when the FAT window closes", source, StringComparison.Ordinal); + Assert.DoesNotContain("new CheckBox", source, StringComparison.Ordinal); + Assert.DoesNotContain("SetClockSyncEnabledAsync", source, StringComparison.Ordinal); + Assert.DoesNotContain("ClockSyncCheckBox_Changed", source, StringComparison.Ordinal); + } + + [Fact] + public void GlobalService_RemainsARealUdp123ServerAndMode5Broadcaster() + { + var source = File.ReadAllText(FindRepoFile("Services/SntpClockService.cs")); + + Assert.Contains("new IPEndPoint(binding.LocalAddress, 123)", source, StringComparison.Ordinal); + Assert.Contains("SntpPacket.BuildServerReply", source, StringComparison.Ordinal); + Assert.Contains("SntpPacket.BuildBroadcast", source, StringComparison.Ordinal); + Assert.Contains("Mode 5 broadcast", source, StringComparison.Ordinal); + Assert.Contains("SendBroadcastPacketAsync", source, StringComparison.Ordinal); + Assert.Contains("TimeSpan.FromSeconds(64)", source, StringComparison.Ordinal); + } + + 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($"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} From 62a82747136099d3b4a377ea7f8bceb234a1d85c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 23:24:32 +0700 Subject: [PATCH 25/51] Preserve explicit Static DataSet authority across FAT --- MainWindow.SharedSclWorkspace.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/MainWindow.SharedSclWorkspace.cs b/MainWindow.SharedSclWorkspace.cs index 5f633c5b8..dc8973db4 100644 --- a/MainWindow.SharedSclWorkspace.cs +++ b/MainWindow.SharedSclWorkspace.cs @@ -21,6 +21,16 @@ private enum SclSignalSelectionMode private readonly HashSet _sharedSclSelectionAuthorityDeviceIds = new(StringComparer.OrdinalIgnoreCase); + // Keep the operator's acquisition intent independently of transient runtime teardown. + // FAT and Engineering share one device/workspace, so entering FAT must never demote an + // explicitly selected Static DataSet report-only workspace into generic Hybrid/MMS. + private readonly HashSet _sharedSclStaticDataSetAuthorityDeviceIds = + new(StringComparer.OrdinalIgnoreCase); + + private bool IsSharedStaticDataSetAuthority(Iec61850MonitorDevice device) + => _sharedSclStaticDataSetAuthorityDeviceIds.Contains(device.DeviceId) || + Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device); + private SclSignalSelectionMode? PromptSclSignalSelectionMode(Window owner, int iedCount) { var dialog = new SclSignalSelectionModeWindow(iedCount) @@ -59,6 +69,7 @@ private void ApplyStaticDataSetSelection(Iec61850MonitorDevice device) SynchronizeAllEngineeringSelectionsToFat(device); _sharedSclSelectionAuthorityDeviceIds.Add(device.DeviceId); + _sharedSclStaticDataSetAuthorityDeviceIds.Add(device.DeviceId); SaveSignalSelectionMemory(device); device.RefreshComputed(); @@ -85,6 +96,7 @@ private void ClearSharedSignalSelection(Iec61850MonitorDevice device) private void MarkSharedSelectionAuthority(Iec61850MonitorDevice device) { // Manual selection restores the normal Smart/Hybrid acquisition contract. + _sharedSclStaticDataSetAuthorityDeviceIds.Remove(device.DeviceId); Iec61850MonitoringModeRegistry.UseHybrid(device); _sharedSclSelectionAuthorityDeviceIds.Add(device.DeviceId); SaveSignalSelectionMemory(device); From f5c5f7f9e24c722ce798f825f5b2398efa1ea43b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 23:24:48 +0700 Subject: [PATCH 26/51] Keep FAT on shared Static DataSet runtime --- MainWindow.IoTesting.MultiIedMonitor.cs | 29 ++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/MainWindow.IoTesting.MultiIedMonitor.cs b/MainWindow.IoTesting.MultiIedMonitor.cs index c6b405911..6353fdc5a 100644 --- a/MainWindow.IoTesting.MultiIedMonitor.cs +++ b/MainWindow.IoTesting.MultiIedMonitor.cs @@ -1,4 +1,5 @@ using ArIED61850Tester.Models; +using ArIED61850Tester.Services; using ArIED61850Tester.Services.IoTesting; namespace ArIED61850Tester; @@ -19,6 +20,11 @@ private async Task StartIoFatDeviceMonitorAsync( ArgumentNullException.ThrowIfNull(device); ArgumentNullException.ThrowIfNull(acquisitionSignals); + // Engineering and FAT are two views of the same SCL workspace. Remember the + // operator's explicit Static DataSet intent independently of any transient monitor + // stop/restart that FAT performs while preparing its evidence scope. + var preserveStaticDataSetAuthority = IsSharedStaticDataSetAuthority(device); + var acquisition = acquisitionSignals .Where(signal => signal.CanPublishToRuntime) .GroupBy( @@ -52,7 +58,21 @@ private async Task StartIoFatDeviceMonitorAsync( RemoveDevicePoints(device.DeviceId); device.Points.Clear(); - SetStatus($"{device.Name}: starting independent FAT live acquisition…"); + // P0 cross-mode authority guard: FAT must never silently turn an Engineering + // Static DataSet workspace into generic Hybrid/MMS acquisition. Reassert the + // protocol contract immediately before the new runtime snapshots its mode. + if (preserveStaticDataSetAuthority) + { + Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device); + AddLog( + "INFO", + device.Name, + "FAT reuses the shared Static DataSet report-only authority; cyclic MMS process polling remains disabled."); + } + + SetStatus(preserveStaticDataSetAuthority + ? $"{device.Name}: starting shared Static DataSet FAT acquisition…" + : $"{device.Name}: starting independent FAT live acquisition…"); var points = await _runtime.StartMonitoringAsync( device, acquisition, @@ -86,6 +106,13 @@ private async Task StartIoFatDeviceMonitorAsync( foreach (var (signal, wasSelected) in originalSelection) signal.IsSelected = wasSelected; device.EndBulkSignalSelection(); + + // A temporary FAT acquisition selection must not alter the shared protocol + // authority either. This also restores AllowDynamicDataSetWrites=false if an + // older FAT preparation path set that compatibility flag while entering FAT. + if (preserveStaticDataSetAuthority) + Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device); + device.RefreshComputed(); RaiseWorkspaceCounts(); } From 1964c64960c552b7d1d3113c91292eef1a99f6a7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 23:25:06 +0700 Subject: [PATCH 27/51] Guard Static DataSet authority across FAT mode --- .../StaticDataSetReportOnlyModeRegressionTests.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs index 6827ca383..d7dd0792d 100644 --- a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs +++ b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs @@ -73,6 +73,21 @@ public void StaticDataSetRegressionGuard_PreservesFa16ReportOnlyDirection() Assert.DoesNotContain("UseStaticDataSetWithMmsFallback", registry, StringComparison.Ordinal); } + [Fact] + public void FatMode_ReusesSharedStaticDataSetAuthority_InsteadOfDemotingToMms() + { + var shared = File.ReadAllText(FindRepoFile("MainWindow.SharedSclWorkspace.cs")); + var fatMonitor = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.MultiIedMonitor.cs")); + + Assert.Contains("_sharedSclStaticDataSetAuthorityDeviceIds", shared, StringComparison.Ordinal); + Assert.Contains("_sharedSclStaticDataSetAuthorityDeviceIds.Add(device.DeviceId)", shared, StringComparison.Ordinal); + Assert.Contains("_sharedSclStaticDataSetAuthorityDeviceIds.Remove(device.DeviceId)", shared, StringComparison.Ordinal); + Assert.Contains("IsSharedStaticDataSetAuthority(device)", fatMonitor, StringComparison.Ordinal); + Assert.Contains("Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device)", fatMonitor, StringComparison.Ordinal); + Assert.Contains("FAT reuses the shared Static DataSet report-only authority", fatMonitor, StringComparison.Ordinal); + Assert.DoesNotContain("Iec61850MonitoringModeRegistry.UseHybrid(device)", fatMonitor, StringComparison.Ordinal); + } + private static SignalDefinition Signal(string reference, string dataSetReference) => new() { From 867fd0abd7c480f66c71b8efff00686c030a3dfe Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 23:52:20 +0700 Subject: [PATCH 28/51] Make FAT consume shared SCL acquisition session --- MainWindow.IoTesting.AutoConnect.cs | 227 ++++++++++++++++++++-------- 1 file changed, 164 insertions(+), 63 deletions(-) diff --git a/MainWindow.IoTesting.AutoConnect.cs b/MainWindow.IoTesting.AutoConnect.cs index 7ace02f5f..cc05bd11d 100644 --- a/MainWindow.IoTesting.AutoConnect.cs +++ b/MainWindow.IoTesting.AutoConnect.cs @@ -13,6 +13,11 @@ public partial class MainWindow /// Prepares one imported IO-list IED for monitoring-only FAT acquisition. Calls for /// different IEDs are independent and may overlap; the IED model itself owns the /// preparation flag, while live binding is deliberately scoped to this IED only. + /// + /// For an SCL workspace already owned by Engineering, FAT is a consumer of the + /// existing per-IED acquisition session. It must never stop/restart that session merely + /// to change evidence scope or polling cadence. Legacy workbook-only FAT remains on the + /// compatibility acquisition path until it is migrated to the same shared-session model. /// internal async Task PrepareIoTestIedForFatAsync( IoTestProject project, @@ -103,11 +108,21 @@ void ReportProgress(string message) // connection path can then perform only TCP/ACSE/MMS association; Re-scan remains // the explicit engineering action for full live discovery/comparison. var hasSclRuntimeAuthority = AttachIoFatSclRuntimeAuthority(ied, device); - - // Monitoring-only toward the process: no control commands are executed. FAT uses - // deterministic fast MMS acquisition for digital commissioning points while the - // normal engineering workspace keeps report-first behavior for ordinary intervals. - device.AllowDynamicDataSetWrites = true; + var sharedStaticDataSetAuthority = + hasSclRuntimeAuthority && IsSharedStaticDataSetAuthority(device); + var reuseSharedSclAcquisition = + hasSclRuntimeAuthority && + (_sharedSclSelectionAuthorityDeviceIds.Contains(device.DeviceId) || + sharedStaticDataSetAuthority); + + // Static DataSet is an operator acquisition contract, not a FAT-local preference. + // Never turn dynamic DataSet writes back on while FAT attaches to that shared + // Engineering session. Legacy/non-shared FAT keeps its historical compatibility + // behavior for now. + if (sharedStaticDataSetAuthority) + Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device); + else if (!reuseSharedSclAcquisition) + device.AllowDynamicDataSetWrites = true; try { @@ -186,7 +201,9 @@ void ReportProgress(string message) // pass and used to hold this SIPROTEC FAT preparation for about one minute. // Explicit Re-scan remains the design-versus-live comparison action. IoTestReconciliationCache.Invalidate(device); - ReportProgress("Using authoritative SCL workspace identity · starting live acquisition"); + ReportProgress(reuseSharedSclAcquisition + ? "Using shared Engineering SCL identity · FAT will consume the existing acquisition session" + : "Using authoritative SCL workspace identity · starting live acquisition"); } else { @@ -289,9 +306,9 @@ void ReportProgress(string message) device.DeviceId); } - // P1 acquisition scope is the safe model match set, not the operator TEST - // selection. Keep Engineering selection and FAT TEST untouched while arming - // all proven shared-workspace-selected rows for this isolated IED runtime. + // FAT evidence scope is a consumer-side projection. It must not redefine the + // SCL acquisition session owned by Engineering. For legacy/non-shared FAT this + // list is still used by the compatibility monitor path below. var acquisitionSignals = selection.Matches .Select(match => match.Signal) .Where(signal => signal.CanPublishToRuntime) @@ -317,13 +334,15 @@ void ReportProgress(string message) match.Signal.ObjectReference); } - // Time synchronization is device-level FAT evidence rather than an ON/OFF - // test point. If the live model exposes an explicit status (for example - // SIPROTEC TimeSynchrnz or MiCOM LLN0.SyncSt), arm that one extra read-only - // signal so the FAT window can capture the real IED value automatically. - IoFatSupplementalEvidenceService.EnsureTimeSyncSignalSelected(device); + // Time-sync evidence must not contaminate a strict Static DataSet selection. + // If the status point is already part of the shared session we can consume it; + // otherwise SNTP/clock evidence remains device-level and FAT must not add a + // process point that would force an acquisition restart. + if (!reuseSharedSclAcquisition) + IoFatSupplementalEvidenceService.EnsureTimeSyncSignalSelected(device); var timeSyncSignal = IoFatSupplementalEvidenceService.FindTimeSyncSignal(device); - if (timeSyncSignal?.CanPublishToRuntime == true && + if (!reuseSharedSclAcquisition && + timeSyncSignal?.CanPublishToRuntime == true && acquisitionSignals.All(signal => !ReferenceEquals(signal, timeSyncSignal))) { acquisitionSignals.Add(timeSyncSignal); @@ -334,52 +353,88 @@ void ReportProgress(string message) RaiseWorkspaceCounts(); _ioTestLiveBindingService.BindIed(ied, Devices); - var fatPollingNotActive = device.IsMonitoring && - device.Points.Any(point => point.PollingIntervalMs > 500); - var requestedAcquisitionReferences = acquisitionSignals - .Select(signal => IoTestLiveBindingService.NormalizeReference(signal.ObjectReference)) - .Where(reference => reference.Length > 0) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - var activeAcquisitionReferences = device.Points - .Select(point => IoTestLiveBindingService.NormalizeReference(point.IecReference)) - .Where(reference => reference.Length > 0) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - var acquisitionScopeChanged = - !requestedAcquisitionReferences.SetEquals(activeAcquisitionReferences); - - // Each IED monitor is isolated. Refresh only this device when its acquisition - // scope or FAT polling cadence changed; another connected/monitoring IED is - // never stopped or rebound by this preparation. - if (device.IsMonitoring && (acquisitionScopeChanged || fatPollingNotActive)) + + if (reuseSharedSclAcquisition) { - ReportProgress("Refreshing this IED's FAT acquisition · other IED monitors remain active"); - await StopDeviceMonitorAsync(device); - } + // P0 lifecycle rule: one IED owns one acquisition session. FAT is only a + // consumer. It must not call StopDeviceMonitorAsync, StartIoFatDeviceMonitorAsync, + // or alter polling cadence merely because its evidence scope differs. + if (sharedStaticDataSetAuthority) + Iec61850MonitoringModeRegistry.UseStaticDataSetReportOnly(device); + + if (!device.IsMonitoring) + { + ReportProgress(sharedStaticDataSetAuthority + ? "Starting the shared Static DataSet acquisition session · FAT will subscribe to it" + : "Starting the shared Engineering acquisition session · FAT will subscribe to it"); + if (!await StartDeviceMonitorAsync(device, navigateToExplorer: false)) + { + _ioTestLiveBindingService.BindIed(ied, Devices); + return IoTestSessionActionResult.Failure( + $"{ied.IedName} connected, but ARSAS could not start the shared Engineering acquisition session required by FAT."); + } + } + else + { + ReportProgress("FAT attached to the existing shared acquisition session · no monitor restart"); + AddLog( + "INFO", + "IO Testing", + $"{ied.IedName}: FAT attached as a consumer of the existing Engineering acquisition session. Session ownership, RCB state and acquisition mode were not restarted or replaced."); + } - if (!device.IsMonitoring) + if (sharedStaticDataSetAuthority) + { + AddLog( + "INFO", + device.Name, + "Static DataSet lifecycle evidence: configured RCB control-plane setup includes GI; waiting for actual InformationReport traffic. FAT will not use MMS process polling as a substitute."); + _ = ObserveSharedStaticReportEvidenceAsync(device, TimeSpan.FromSeconds(3)); + } + } + else { - ReportProgress("Starting independent FAT live acquisition · fast MMS verification"); - // Compatibility note: the pre-P1 source contract called StartDeviceMonitorAsync - // here. P1 routes through StartIoFatDeviceMonitorAsync so acquisition scope can - // be armed without mutating/persisting the operator TEST selection. - if (!await StartIoFatDeviceMonitorAsync(device, acquisitionSignals)) + var fatPollingNotActive = device.IsMonitoring && + device.Points.Any(point => point.PollingIntervalMs > 500); + var requestedAcquisitionReferences = acquisitionSignals + .Select(signal => IoTestLiveBindingService.NormalizeReference(signal.ObjectReference)) + .Where(reference => reference.Length > 0) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var activeAcquisitionReferences = device.Points + .Select(point => IoTestLiveBindingService.NormalizeReference(point.IecReference)) + .Where(reference => reference.Length > 0) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var acquisitionScopeChanged = + !requestedAcquisitionReferences.SetEquals(activeAcquisitionReferences); + + // Compatibility path for non-shared/legacy FAT only. Shared SCL workspaces + // never enter this stop/restart branch. + if (device.IsMonitoring && (acquisitionScopeChanged || fatPollingNotActive)) { - _ioTestLiveBindingService.BindIed(ied, Devices); - return IoTestSessionActionResult.Failure( - $"{ied.IedName} connected, but ARSAS could not start live acquisition for the imported FAT scope."); + ReportProgress("Refreshing this legacy FAT IED acquisition · other IED monitors remain active"); + await StopDeviceMonitorAsync(device); + } + + if (!device.IsMonitoring) + { + ReportProgress("Starting legacy FAT live acquisition · fast MMS verification"); + if (!await StartIoFatDeviceMonitorAsync(device, acquisitionSignals)) + { + _ioTestLiveBindingService.BindIed(ied, Devices); + return IoTestSessionActionResult.Failure( + $"{ied.IedName} connected, but ARSAS could not start live acquisition for the imported FAT scope."); + } } } - // The monitor points now exist and MMS polling has already been scheduled. - // Return control to FAT immediately: waiting here for values/report setup made - // a sub-second SCL association look like a 30-50 second connection whenever - // the UI dispatcher was busy with the first report burst. Values and report - // optimization continue through the shared Engineering runtime. + // Bind FAT rows to the already-owned per-IED monitor points. In a shared SCL + // workspace this is purely a consumer projection; it does not create another + // runtime or change the acquisition method. var binding = _ioTestLiveBindingService.BindIed(ied, Devices); var acquisition = ReadIoFatAcquisitionSummary(requestedPoints, device); - var liveCount = requestedPoints.Count(point => + var boundCount = requestedPoints.Count(point => point.LiveBindingState == IoTestLiveBindingState.LivePointReady); - if (liveCount == 0) + if (boundCount == 0) { var unresolved = requestedPoints .Take(4) @@ -388,28 +443,31 @@ void ReportProgress(string message) $"{ied.IedName} is connected and monitoring, but none of the {requestedPoints.Count} requested FAT signal(s) has a unique live monitor point. Unresolved: {string.Join(", ", unresolved)}."); } - // The temporary acquisition arm has already been restored by - // StartIoFatDeviceMonitorAsync. Persist only the real operator selection. SaveSignalSelectionMemory(device); var modelText = hasSclRuntimeAuthority ? "imported SCL model" : usedSavedModel ? "saved model" : "live model"; - var acquisitionText = acquisition.PollingCount == 0 - ? $"report-backed {acquisition.ReportCount}/{liveCount}" - : $"fast MMS {acquisition.PollingCount} · report-backed {acquisition.ReportCount}"; + var acquisitionText = sharedStaticDataSetAuthority + ? $"static report live {acquisition.ReportCount} · pending/unavailable {acquisition.UnknownCount} · MMS polling {acquisition.PollingCount}" + : acquisition.PollingCount == 0 + ? $"report-backed {acquisition.ReportCount}/{boundCount}" + : $"fast MMS {acquisition.PollingCount} · report-backed {acquisition.ReportCount}"; var timeSyncText = IoFatSupplementalEvidenceService.FindTimeSyncSignal(device) == null ? " · time-sync fallback ready" - : " · time-sync status armed"; - var unresolvedCount = requestedPoints.Count - liveCount; + : reuseSharedSclAcquisition + ? " · time-sync evidence observed from shared scope only" + : " · time-sync status armed"; + var unresolvedCount = requestedPoints.Count - boundCount; var partialText = unresolvedCount == 0 ? string.Empty : $" · {unresolvedCount} FAT row(s) waiting for safe live binding"; - var message = $"{ied.IedName} · {liveCount}/{requestedPoints.Count} live · {acquisitionText}{timeSyncText}{partialText}"; + var sessionText = reuseSharedSclAcquisition ? " · shared acquisition session" : string.Empty; + var message = $"{ied.IedName} · {boundCount}/{requestedPoints.Count} bound · {acquisitionText}{timeSyncText}{partialText}{sessionText}"; SetStatus(message); AddLog( unresolvedCount == 0 ? "INFO" : "WARN", "IO Testing", - $"{message}. Live rows are usable immediately; unresolved rows stay visible but are excluded from the active evidence scope until a unique live point exists. No checkbox or FAT disposition is changed by the engine. IED live-bound={binding.LivePointCount}; model={modelText}; mode={device.AcquisitionMode}."); + $"{message}. FAT is an evidence consumer; shared SCL acquisition ownership is preserved. No checkbox or FAT disposition is changed by the engine. IED live-bound={binding.LivePointCount}; model={modelText}; mode={device.AcquisitionMode}."); ReportProgress(message); return IoTestSessionActionResult.Success(message); } @@ -434,6 +492,40 @@ void ReportProgress(string message) } } + private async Task ObserveSharedStaticReportEvidenceAsync( + Iec61850MonitorDevice device, + TimeSpan proofWindow) + { + try + { + await Task.Delay(proofWindow, _applicationCancellation.Token); + if (!device.IsMonitoring || !IsSharedStaticDataSetAuthority(device)) + return; + + if (device.HasReportStream) + { + var reportPoints = device.Points.Count(point => + IsReportSource(point.SourceMode) && + !point.SourceMode.Contains("pending", StringComparison.OrdinalIgnoreCase)); + AddLog( + "INFO", + device.Name, + $"Static DataSet report evidence: actual InformationReport traffic observed; report-backed runtime point(s)={reportPoints}. RCB/GI path is alive."); + return; + } + + AddLog( + "WARN", + device.Name, + $"Static DataSet report evidence: configured RCB setup/GI was requested, but no InformationReport traffic was observed within {proofWindow.TotalSeconds:0.#} s. ARSAS will remain report-pending and will NOT switch process values to cyclic MMS polling. Check RptEna/ownership, GI support and whether the IED server actually emits reports for the configured DataSet."); + MarkDiagnosticAlert(); + } + catch (OperationCanceledException) + { + // Application shutdown or explicit lifecycle cancellation needs no warning. + } + } + private bool AttachIoFatSclRuntimeAuthority( IoTestIedPlan ied, Iec61850MonitorDevice device) @@ -604,9 +696,18 @@ private static IoFatAcquisitionSummary ReadIoFatAcquisitionSummary( } private static bool IsReportSource(string source) - => source.Contains("BRCB", StringComparison.OrdinalIgnoreCase) || - source.Contains("URCB", StringComparison.OrdinalIgnoreCase) || - source.Contains("report", StringComparison.OrdinalIgnoreCase); + { + if (string.IsNullOrWhiteSpace(source) || + source.Contains("pending", StringComparison.OrdinalIgnoreCase) || + source.Contains("unavailable", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return source.Contains("BRCB", StringComparison.OrdinalIgnoreCase) || + source.Contains("URCB", StringComparison.OrdinalIgnoreCase) || + source.Contains("report", StringComparison.OrdinalIgnoreCase); + } private sealed record IoFatAcquisitionSummary( int LiveCount, From 25dc3c9eb0c32a0767c7c673ae2446c121d32c8d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 23:52:55 +0700 Subject: [PATCH 29/51] Guard shared SCL acquisition ownership across FAT --- ...ticDataSetReportOnlyModeRegressionTests.cs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs index d7dd0792d..50a41a62b 100644 --- a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs +++ b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs @@ -88,6 +88,59 @@ public void FatMode_ReusesSharedStaticDataSetAuthority_InsteadOfDemotingToMms() Assert.DoesNotContain("Iec61850MonitoringModeRegistry.UseHybrid(device)", fatMonitor, StringComparison.Ordinal); } + [Fact] + public void SharedSclFat_IsConsumerOfExistingAcquisitionSession_NotASecondMonitorOwner() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.AutoConnect.cs")); + + Assert.Contains("var reuseSharedSclAcquisition", source, StringComparison.Ordinal); + Assert.Contains("FAT attached to the existing shared acquisition session · no monitor restart", source, StringComparison.Ordinal); + Assert.Contains("StartDeviceMonitorAsync(device, navigateToExplorer: false)", source, StringComparison.Ordinal); + Assert.Contains("one IED owns one acquisition session", source, StringComparison.Ordinal); + Assert.Contains("FAT is only a", source, StringComparison.Ordinal); + Assert.Contains("consumer", source, StringComparison.Ordinal); + Assert.Contains("if (reuseSharedSclAcquisition)", source, StringComparison.Ordinal); + Assert.Contains("if (!reuseSharedSclAcquisition)", source, StringComparison.Ordinal); + Assert.Contains("Compatibility path for non-shared/legacy FAT only", source, StringComparison.Ordinal); + + // The legacy helper may remain for workbook-only FAT, but the shared SCL branch + // must be structurally separate and must start through the normal Engineering owner. + var sharedBranchStart = source.IndexOf("if (reuseSharedSclAcquisition)", StringComparison.Ordinal); + var legacyBranchStart = source.IndexOf("Compatibility path for non-shared/legacy FAT only", StringComparison.Ordinal); + Assert.True(sharedBranchStart >= 0 && legacyBranchStart > sharedBranchStart); + + var sharedBranch = source[sharedBranchStart..legacyBranchStart]; + Assert.DoesNotContain("StopDeviceMonitorAsync(device)", sharedBranch, StringComparison.Ordinal); + Assert.DoesNotContain("StartIoFatDeviceMonitorAsync(device", sharedBranch, StringComparison.Ordinal); + Assert.DoesNotContain("initial MMS", sharedBranch, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void SharedStaticFat_DoesNotAddSupplementalSignalOrMisreportPendingAsReportBacked() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.AutoConnect.cs")); + + Assert.Contains("if (!reuseSharedSclAcquisition)", source, StringComparison.Ordinal); + Assert.Contains("IoFatSupplementalEvidenceService.EnsureTimeSyncSignalSelected(device)", source, StringComparison.Ordinal); + Assert.Contains("source.Contains(\"pending\"", source, StringComparison.Ordinal); + Assert.Contains("source.Contains(\"unavailable\"", source, StringComparison.Ordinal); + Assert.Contains("static report live", source, StringComparison.Ordinal); + Assert.Contains("pending/unavailable", source, StringComparison.Ordinal); + Assert.Contains("MMS polling", source, StringComparison.Ordinal); + } + + [Fact] + public void SharedStaticFat_EmitsCausalReportEvidenceWithoutMmsFallback() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.AutoConnect.cs")); + + Assert.Contains("ObserveSharedStaticReportEvidenceAsync", source, StringComparison.Ordinal); + Assert.Contains("waiting for actual InformationReport traffic", source, StringComparison.Ordinal); + Assert.Contains("actual InformationReport traffic observed", source, StringComparison.Ordinal); + Assert.Contains("no InformationReport traffic was observed", source, StringComparison.Ordinal); + Assert.Contains("will NOT switch process values to cyclic MMS polling", source, StringComparison.Ordinal); + } + private static SignalDefinition Signal(string reference, string dataSetReference) => new() { From c1bbbf4d5def4680c7b23398942bd5c5f688762e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 23:54:32 +0700 Subject: [PATCH 30/51] Add causal Static DataSet RCB feasibility evidence --- MainWindow.StaticReportEvidence.cs | 132 +++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 MainWindow.StaticReportEvidence.cs diff --git a/MainWindow.StaticReportEvidence.cs b/MainWindow.StaticReportEvidence.cs new file mode 100644 index 000000000..07b618564 --- /dev/null +++ b/MainWindow.StaticReportEvidence.cs @@ -0,0 +1,132 @@ +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + /// + /// Emits a protocol-feasibility inventory before monitoring starts. A configured + /// DataSet is not automatically reportable: at least one configured BRCB/URCB must + /// reference it. This diagnostic deliberately does not infer availability from + /// freeBRCB/freeURCB counts or from process-value polling. + /// + private void LogStaticDataSetReportFeasibility(Iec61850MonitorDevice device) + { + var model = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel; + if (model is null) + { + AddLog("WARN", device.Name, + "Static DataSet feasibility: no SCL/live design model is available; configured RCB → DataSet mapping cannot be audited."); + return; + } + + var reports = model.ReportControls.ToArray(); + foreach (var dataSet in model.DataSets) + { + var matchingReports = reports + .Where(report => SameSclReference(report.DataSetReference, dataSet.Reference)) + .ToArray(); + + if (matchingReports.Length == 0) + { + AddLog( + "WARN", + device.Name, + $"Static DataSet feasibility: {dataSet.Reference} · members={dataSet.Members.Count} · NO CONFIGURED RCB. These members cannot become report-live until the IED/SCL provides a BRCB/URCB for this DataSet; ARSAS will not substitute cyclic MMS process polling."); + continue; + } + + var reportSummary = string.Join(", ", matchingReports.Select(report => + $"{report.Reference} ({(report.Buffered ? "BRCB" : "URCB")})")); + AddLog( + "INFO", + device.Name, + $"Static DataSet feasibility: {dataSet.Reference} · members={dataSet.Members.Count} · configured RCB={reportSummary}."); + } + } + + /// + /// Starts a causal proof timer from the operator's initial Static DataSet selection. + /// It waits for the shared Engineering monitor to exist, labels points whose DataSet + /// has no configured RCB, then checks for actual InformationReport traffic. A successful + /// RptEna/GI write alone is intentionally not treated as report proof. + /// + private async Task ObserveInitialStaticReportEvidenceAsync(Iec61850MonitorDevice device) + { + try + { + var monitorDeadline = DateTime.UtcNow.AddSeconds(12); + while (!device.IsMonitoring && DateTime.UtcNow < monitorDeadline) + { + if (!IsSharedStaticDataSetAuthority(device)) + return; + await Task.Delay(100, _applicationCancellation.Token); + } + + if (!device.IsMonitoring || !IsSharedStaticDataSetAuthority(device)) + return; + + ApplyNoConfiguredRcbPointEvidence(device); + AddLog( + "INFO", + device.Name, + "Static DataSet causal gate: shared monitor started. RCB control-plane setup may use one-shot MMS; cyclic MMS process-value polling remains forbidden. Waiting for actual InformationReport traffic after GI."); + await ObserveSharedStaticReportEvidenceAsync(device, TimeSpan.FromSeconds(3)); + } + catch (OperationCanceledException) + { + // Application shutdown or lifecycle cancellation needs no warning. + } + } + + private void ApplyNoConfiguredRcbPointEvidence(Iec61850MonitorDevice device) + { + var model = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel; + if (model is null || device.Points.Count == 0) + return; + + var reportableDataSets = model.ReportControls + .Where(report => !string.IsNullOrWhiteSpace(report.DataSetReference)) + .Select(report => NormalizeSclReference(report.DataSetReference)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var selectedSignals = device.Signals + .Where(signal => signal.IsSelected && !string.IsNullOrWhiteSpace(signal.DataSetReference)) + .GroupBy(signal => NormalizeSclReference(signal.ObjectReference), StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + + var unavailable = 0; + foreach (var point in device.Points) + { + if (!selectedSignals.TryGetValue(NormalizeSclReference(point.IecReference), out var signal)) + continue; + + var dataSetReference = NormalizeSclReference(signal.DataSetReference); + if (dataSetReference.Length == 0 || reportableDataSets.Contains(dataSetReference)) + continue; + + point.SourceMode = "Static DataSet: no configured RCB"; + point.Status = "Unavailable / no configured RCB"; + unavailable++; + } + + if (unavailable > 0) + { + AddLog( + "WARN", + device.Name, + $"Static DataSet feasibility projected to runtime: {unavailable} point(s) belong to DataSet(s) with no configured RCB and are explicitly marked unavailable instead of generic report-pending or MMS polling."); + } + } + + private static bool SameSclReference(string? left, string? right) + => string.Equals( + NormalizeSclReference(left), + NormalizeSclReference(right), + StringComparison.OrdinalIgnoreCase); + + private static string NormalizeSclReference(string? value) + => string.IsNullOrWhiteSpace(value) + ? string.Empty + : value.Trim().Replace('$', '.'); +} From 582089402637b93680a2cefc4f2ee6893c251140 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 23:54:57 +0700 Subject: [PATCH 31/51] Audit Static DataSet report feasibility from initial selection --- MainWindow.SharedSclWorkspace.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/MainWindow.SharedSclWorkspace.cs b/MainWindow.SharedSclWorkspace.cs index dc8973db4..2531c48f5 100644 --- a/MainWindow.SharedSclWorkspace.cs +++ b/MainWindow.SharedSclWorkspace.cs @@ -77,6 +77,12 @@ private void ApplyStaticDataSetSelection(Iec61850MonitorDevice device) "INFO", device.Name, $"Static DataSet report-only authority selected: {device.SelectedLiveSignalCount} exact runtime member row(s) from {merge.MandatoryCatalogCount} ARIEC static membership descriptor(s); cyclic MMS process polling and dynamic DataSet writes remain disabled."); + + // Make feasibility and first-report proof visible from the initial Engineering + // workflow rather than waiting until FAT is opened. The observer waits for the + // shared monitor to start and never changes acquisition method. + LogStaticDataSetReportFeasibility(device); + _ = ObserveInitialStaticReportEvidenceAsync(device); } private void ClearSharedSignalSelection(Iec61850MonitorDevice device) From 9e14e617497a515a6e55080830c212ee21fc355a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 23:56:59 +0700 Subject: [PATCH 32/51] Align FAT P0 regression with shared acquisition ownership --- tests/ARSAS.Tests/IoTestFatP0RegressionTests.cs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/ARSAS.Tests/IoTestFatP0RegressionTests.cs b/tests/ARSAS.Tests/IoTestFatP0RegressionTests.cs index 9048e1342..9f562587a 100644 --- a/tests/ARSAS.Tests/IoTestFatP0RegressionTests.cs +++ b/tests/ARSAS.Tests/IoTestFatP0RegressionTests.cs @@ -8,9 +8,10 @@ public void FatWorkspace_UsesFastVerificationCadenceWithoutBypassingReportFirstP var main = Read("MainWindow.IoTesting.cs"); var planner = Read("Services/Iec61850ReportPlanner.cs"); - // FAT may temporarily tighten MMS verification cadence, but that cadence must not - // classify fast status/protection points as polling-only. Report planning remains - // static first, then dynamic, with MMS polling reserved for the residual fallback. + // Legacy workbook-only FAT may temporarily tighten MMS verification cadence, but + // that cadence must not classify fast status/protection points as polling-only. + // Shared SCL workspaces are guarded separately: FAT consumes the Engineering + // acquisition session and never owns a second polling runtime. Assert.Contains("private const int IoFatPollingIntervalMs = 250;", main, StringComparison.Ordinal); Assert.Contains("_pollingIntervalBeforeIoFat", main, StringComparison.Ordinal); Assert.Contains("PollingIntervalMs = Math.Min(PollingIntervalMs, IoFatPollingIntervalMs);", main, StringComparison.Ordinal); @@ -22,13 +23,15 @@ public void FatWorkspace_UsesFastVerificationCadenceWithoutBypassingReportFirstP } [Fact] - public void FatConnect_ReadinessDoesNotBlockOnLegacyEightToEighteenSecondReportSettle() + public void SharedSclFat_ReadinessDoesNotRestartMonitorOrWaitForLegacyReportSettle() { var source = Read("MainWindow.IoTesting.AutoConnect.cs"); - Assert.Contains("Return control to FAT immediately", source, StringComparison.Ordinal); + Assert.Contains("reuseSharedSclAcquisition", source, StringComparison.Ordinal); + Assert.Contains("FAT attached to the existing shared acquisition session · no monitor restart", source, StringComparison.Ordinal); + Assert.Contains("StartDeviceMonitorAsync(device, navigateToExplorer: false)", source, StringComparison.Ordinal); + Assert.Contains("Compatibility path for non-shared/legacy FAT only", source, StringComparison.Ordinal); Assert.DoesNotContain("TimeSpan.FromMilliseconds(2500)", source, StringComparison.Ordinal); - Assert.Contains("fast MMS verification", source, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("TimeSpan.FromSeconds(8)", source, StringComparison.Ordinal); Assert.DoesNotContain("TimeSpan.FromSeconds(10)", source, StringComparison.Ordinal); Assert.DoesNotContain("rebuilding the report plan once", source, StringComparison.OrdinalIgnoreCase); From 5374970dbb90a552d03b8d97799de3e1443b8f8f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 23:57:13 +0700 Subject: [PATCH 33/51] Treat FAT live rows as shared-session bindings --- tests/ARSAS.Tests/IoFatP53PartialLiveRegressionTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/IoFatP53PartialLiveRegressionTests.cs b/tests/ARSAS.Tests/IoFatP53PartialLiveRegressionTests.cs index 6399a9938..929cfe1b4 100644 --- a/tests/ARSAS.Tests/IoFatP53PartialLiveRegressionTests.cs +++ b/tests/ARSAS.Tests/IoFatP53PartialLiveRegressionTests.cs @@ -5,14 +5,15 @@ namespace ARSAS.Tests; public sealed class IoFatP53PartialLiveRegressionTests { [Fact] - public void Preparation_AllowsUsablePartialSclLiveScopeWithoutMutatingOperatorSelection() + public void Preparation_AllowsUsablePartialSclBindingWithoutMutatingOperatorSelection() { var source = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.AutoConnect.cs")); Assert.Contains("mayProceedWithPartialSclSelection", source, StringComparison.Ordinal); Assert.Contains("selection.Matches.Count > 0", source, StringComparison.Ordinal); Assert.Contains("unresolvedSelectionPoints.All(IoTestSignalSelectionService.IsDirectSclAuthority)", source, StringComparison.Ordinal); - Assert.Contains("if (liveCount == 0)", source, StringComparison.Ordinal); + Assert.Contains("if (boundCount == 0)", source, StringComparison.Ordinal); + Assert.Contains("FAT is an evidence consumer", source, StringComparison.Ordinal); Assert.DoesNotContain("point.TestEnabled = false", source, StringComparison.Ordinal); Assert.DoesNotContain("RemoveFromFat()", source, StringComparison.Ordinal); Assert.Contains("checkbox or FAT disposition is changed by the engine", source, StringComparison.OrdinalIgnoreCase); From 577ac4b2b4ea34b0d12692e1c018ebbd22b86d93 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 23:58:02 +0700 Subject: [PATCH 34/51] Make IO FAT UI contract require shared SCL acquisition ownership --- tests/ARSAS.Tests/IoTestingUiContractTests.cs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/ARSAS.Tests/IoTestingUiContractTests.cs b/tests/ARSAS.Tests/IoTestingUiContractTests.cs index 1ad83cc40..1e430db40 100644 --- a/tests/ARSAS.Tests/IoTestingUiContractTests.cs +++ b/tests/ARSAS.Tests/IoTestingUiContractTests.cs @@ -162,15 +162,16 @@ public void IoTestingWindow_UsesSelectedIedContextAndWorkspacePreviewToggle() } [Fact] - public void IoFatAutomaticPreparation_UsesFastCommissioningAcquisitionWithoutProcessControls() + public void IoFatAutomaticPreparation_ReusesSharedSclAcquisitionWithoutProcessControls() { var source = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.AutoConnect.cs")); - Assert.Contains("AllowDynamicDataSetWrites = true", source, StringComparison.Ordinal); - Assert.Contains("ConnectAndConfigureDeviceAsync", source, StringComparison.Ordinal); - Assert.Contains("StartDeviceMonitorAsync", source, StringComparison.Ordinal); - Assert.Contains("deterministic fast MMS", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Return control to FAT immediately", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("reuseSharedSclAcquisition", source, StringComparison.Ordinal); + Assert.Contains("sharedStaticDataSetAuthority", source, StringComparison.Ordinal); + Assert.Contains("StartDeviceMonitorAsync(device, navigateToExplorer: false)", source, StringComparison.Ordinal); + Assert.Contains("FAT attached to the existing shared acquisition session · no monitor restart", source, StringComparison.Ordinal); + Assert.Contains("FAT is an evidence consumer", source, StringComparison.Ordinal); + Assert.Contains("Compatibility path for non-shared/legacy FAT only", source, StringComparison.Ordinal); Assert.DoesNotContain("SettleIoFatReportPriorityAsync", source, StringComparison.Ordinal); Assert.DoesNotContain("TimeSpan.FromMilliseconds(2500)", source, StringComparison.Ordinal); Assert.DoesNotContain("rebuilding the report plan once", source, StringComparison.OrdinalIgnoreCase); From cfe8fa19908ca11db47ccc4b1d80d7112db1afac Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 23:58:39 +0700 Subject: [PATCH 35/51] Require cached FAT preparation to preserve shared monitor ownership --- tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs b/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs index 047813963..74d798cdf 100644 --- a/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs +++ b/tests/ARSAS.Tests/IoFatReportPreviewServiceTests.cs @@ -160,13 +160,16 @@ public void PreviewKeepsProgressOnIedCardOnly() } [Fact] - public void IoFatPreparation_UsesCachedReconnect_AndReturnsImmediatelyAfterMonitorStart() + public void IoFatPreparation_UsesCachedReconnect_AndAttachesToSharedMonitorWithoutRestart() { var source = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.AutoConnect.cs")); Assert.Contains("Fast reconnect", source, StringComparison.Ordinal); Assert.Contains("ConnectUsingSavedModelAsync", source, StringComparison.Ordinal); - Assert.Contains("Return control to FAT immediately", source, StringComparison.Ordinal); + Assert.Contains("reuseSharedSclAcquisition", source, StringComparison.Ordinal); + Assert.Contains("FAT attached to the existing shared acquisition session · no monitor restart", source, StringComparison.Ordinal); + Assert.Contains("StartDeviceMonitorAsync(device, navigateToExplorer: false)", source, StringComparison.Ordinal); + Assert.Contains("Bind FAT rows to the already-owned per-IED monitor points", source, StringComparison.Ordinal); Assert.DoesNotContain("SettleIoFatReportPriorityAsync", source, StringComparison.Ordinal); Assert.DoesNotContain("TimeSpan.FromMilliseconds(2500)", source, StringComparison.Ordinal); Assert.Contains("IsReportSource", source, StringComparison.Ordinal); From dadc5cebbd22673bf5b8b466b1cb659f2aa9fd69 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 09:03:12 +0700 Subject: [PATCH 36/51] P0: keep IEC61850 lifecycle work off WPF dispatcher --- ...iResponsiveIec61850MonitorRuntimeFacade.cs | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 Services/UiResponsiveIec61850MonitorRuntimeFacade.cs diff --git a/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs b/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs new file mode 100644 index 000000000..34bb5274f --- /dev/null +++ b/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs @@ -0,0 +1,203 @@ +using System.Collections.Concurrent; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +/// +/// UI-facing facade for the IEC 61850 runtime. +/// +/// The protocol engine intentionally owns MMS serialization, report ownership and per-IED +/// session state. WPF must not, however, execute the synchronous prefix of a native/network +/// operation on the Dispatcher thread. Some vendor/OS socket and teardown paths can block +/// before their first asynchronous yield; when invoked directly from a Click/Closing handler +/// that makes the whole application appear hung even though the API returns Task. +/// +/// This facade is resolved by MainWindow from its own namespace (and wraps the Services +/// runtime explicitly). Every potentially blocking lifecycle/control operation starts on the +/// ThreadPool, while a per-device gate prevents Connect/Start/Stop/Disconnect races for the +/// same IED. Different IEDs remain independent. No polling/read fallback is introduced here. +/// +public sealed class Iec61850MonitorRuntime : IAsyncDisposable +{ + private static readonly TimeSpan DisposeBudget = TimeSpan.FromSeconds(3); + + private readonly Services.Iec61850MonitorRuntime _inner = new(); + private readonly ConcurrentDictionary _deviceLifecycleGates = + new(StringComparer.OrdinalIgnoreCase); + private int _disposeStarted; + + public Iec61850MonitorRuntime() + { + _inner.Diagnostic += entry => Diagnostic?.Invoke(entry); + _inner.PointUpdated += snapshot => PointUpdated?.Invoke(snapshot); + _inner.EventRaised += entry => EventRaised?.Invoke(entry); + } + + public event Action? Diagnostic; + public event Action? PointUpdated; + public event Action? EventRaised; + + public int ConnectedDeviceCount => _inner.ConnectedDeviceCount; + public int MonitoringDeviceCount => _inner.MonitoringDeviceCount; + + public Task> ConnectAndDiscoverAsync( + Iec61850MonitorDevice device, + CancellationToken cancellationToken, + IProgress? progress = null) + => RunDeviceLifecycleAsync( + device?.DeviceId, + cancellationToken, + () => _inner.ConnectAndDiscoverAsync(device, cancellationToken, progress)); + + public Task ConnectUsingCachedModelAsync( + Iec61850MonitorDevice device, + CancellationToken cancellationToken, + IProgress? progress = null) + => RunDeviceLifecycleAsync( + device?.DeviceId, + cancellationToken, + () => _inner.ConnectUsingCachedModelAsync(device, cancellationToken, progress)); + + public Task> StartMonitoringAsync( + Iec61850MonitorDevice device, + IEnumerable selectedSignals, + int pollingIntervalMs, + CancellationToken cancellationToken) + => RunDeviceLifecycleAsync( + device?.DeviceId, + cancellationToken, + () => _inner.StartMonitoringAsync(device, selectedSignals, pollingIntervalMs, cancellationToken)); + + public Task InspectControlAsync( + string deviceId, + SignalDefinition signal, + CancellationToken cancellationToken) + => RunDeviceLifecycleAsync( + deviceId, + cancellationToken, + () => _inner.InspectControlAsync(deviceId, signal, cancellationToken)); + + public Task ExecuteControlAsync( + string deviceId, + Iec61850ControlCommandRequest request, + CancellationToken cancellationToken) + => RunDeviceLifecycleAsync( + deviceId, + cancellationToken, + () => _inner.ExecuteControlAsync(deviceId, request, cancellationToken)); + + public HybridReportPhysicalValidationSnapshot CaptureHybridReportPhysicalValidation(string deviceId) + => _inner.CaptureHybridReportPhysicalValidation(deviceId); + + public Task StopMonitoringAsync(string deviceId) + => RunDeviceLifecycleAsync( + deviceId, + CancellationToken.None, + () => _inner.StopMonitoringAsync(deviceId)); + + public Task StopDeviceAsync(string deviceId) + => RunDeviceLifecycleAsync( + deviceId, + CancellationToken.None, + () => _inner.StopDeviceAsync(deviceId)); + + private async Task RunDeviceLifecycleAsync( + string? deviceId, + CancellationToken cancellationToken, + Func operation) + { + ThrowIfDisposing(); + ArgumentNullException.ThrowIfNull(operation); + + var key = NormalizeDeviceKey(deviceId); + var gate = _deviceLifecycleGates.GetOrAdd(key, static _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + // Task-returning APIs are not automatically non-blocking. Invoke the complete + // operation from the ThreadPool so a blocking native prefix can never seize the + // WPF Dispatcher. Awaiting callers still receive the real completion/exception. + await Task.Run(operation, CancellationToken.None).ConfigureAwait(false); + } + finally + { + gate.Release(); + } + } + + private async Task RunDeviceLifecycleAsync( + string? deviceId, + CancellationToken cancellationToken, + Func> operation) + { + ThrowIfDisposing(); + ArgumentNullException.ThrowIfNull(operation); + + var key = NormalizeDeviceKey(deviceId); + var gate = _deviceLifecycleGates.GetOrAdd(key, static _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await Task.Run(operation, CancellationToken.None).ConfigureAwait(false); + } + finally + { + gate.Release(); + } + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) + return; + + // Dispose on a worker thread as well: a vendor/native close path is allowed to be + // slow, but it is never allowed to freeze the operator's window. MainWindow also has + // its own shutdown budget; this inner bound keeps the facade independently safe. + Task disposeTask; + try + { + disposeTask = Task.Run(async () => await _inner.DisposeAsync().ConfigureAwait(false)); + } + catch + { + return; + } + + var completed = await Task.WhenAny(disposeTask, Task.Delay(DisposeBudget)).ConfigureAwait(false); + if (completed == disposeTask) + { + try + { + await disposeTask.ConfigureAwait(false); + } + catch + { + // Application shutdown is best-effort. The process must remain closable even + // when a native session reports a teardown failure. + } + } + else + { + _ = disposeTask.ContinueWith( + static task => _ = task.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + foreach (var gate in _deviceLifecycleGates.Values) + gate.Dispose(); + _deviceLifecycleGates.Clear(); + } + + private void ThrowIfDisposing() + { + if (Volatile.Read(ref _disposeStarted) != 0) + throw new ObjectDisposedException(nameof(Iec61850MonitorRuntime)); + } + + private static string NormalizeDeviceKey(string? deviceId) + => string.IsNullOrWhiteSpace(deviceId) ? "__unbound__" : deviceId.Trim(); +} From 30d218cea5fcb66e17793186df6e3c305bfedc65 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 09:03:32 +0700 Subject: [PATCH 37/51] P0: make FAT close journal sealing and save non-blocking --- IoListTestingWindow.P0Lifecycle.cs | 118 +++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 IoListTestingWindow.P0Lifecycle.cs diff --git a/IoListTestingWindow.P0Lifecycle.cs b/IoListTestingWindow.P0Lifecycle.cs new file mode 100644 index 000000000..6ab762a01 --- /dev/null +++ b/IoListTestingWindow.P0Lifecycle.cs @@ -0,0 +1,118 @@ +using System.ComponentModel; +using System.IO; +using System.Windows; + +namespace ArIED61850Tester; + +/// +/// P0 responsiveness guard for the FAT workspace lifecycle. +/// +/// The legacy Closing handler sealed evidence journals and flushed persistence synchronously +/// on the WPF Dispatcher. Both operations can touch disk and, during a live session, cascade +/// into runtime teardown. That made Close look like an application hang. Keep confirmation on +/// the UI thread, then perform journal sealing and durable save on a worker thread. +/// +public partial class IoListTestingWindow +{ + private bool _p0CloseInProgress; + private bool _p0AllowClose; + + protected override void OnSourceInitialized(EventArgs e) + { + base.OnSourceInitialized(e); + + // XAML wires Window_Closing during InitializeComponent. Replace only that lifecycle + // edge; all existing OnClosed cleanup remains intact. + Closing -= Window_Closing; + Closing += P0Window_Closing; + } + + private async void P0Window_Closing(object? sender, CancelEventArgs e) + { + if (_p0AllowClose) + return; + + e.Cancel = true; + if (_p0CloseInProgress) + return; + + if (IsPreparingIed) + { + var activeNames = string.Join(", ", Project.Ieds + .Where(ied => ied.IsPreparing) + .Select(ied => ied.IedName)); + MessageBox.Show( + this, + $"ARSAS is still preparing {activeNames}. You can inspect or connect other IEDs while these independent workflows run, but finish preparation before closing this workspace.", + "IED preparation in progress", + MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } + + if (Session.HasActiveSessions) + { + var activeCount = Session.ActiveSessionCount; + var answer = MessageBox.Show( + this, + $"{activeCount} FAT evidence session(s) are active. Returning to Engineering will stop every active IED session, seal each independent evidence journal, and save the current project progress.\n\nStop all sessions and return?", + "Stop active FAT sessions", + MessageBoxButton.YesNo, + MessageBoxImage.Warning, + MessageBoxResult.No); + if (answer != MessageBoxResult.Yes) + return; + } + + _p0CloseInProgress = true; + IsEnabled = false; + + try + { + if (Session.HasActiveSessions) + { + var stopAll = await Task.Run(() => + Session.StopAll("Workspace closed by operator; per-IED evidence journal sealed.")); + if (!stopAll.Succeeded) + { + MessageBox.Show( + this, + stopAll.Message, + "Evidence journals could not be sealed", + MessageBoxButton.OK, + MessageBoxImage.Error); + return; + } + } + + if (Storage != null) + await Task.Run(Storage.SaveNow); + + _p0AllowClose = true; + Close(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + var answer = MessageBox.Show( + this, + $"ARSAS could not save the latest IO FAT progress.\n\n{ex.Message}\n\nClose the workspace anyway?", + "Progress save failed", + MessageBoxButton.YesNo, + MessageBoxImage.Error, + MessageBoxResult.No); + if (answer == MessageBoxResult.Yes) + { + _p0AllowClose = true; + Close(); + } + } + finally + { + if (!_p0AllowClose) + { + _p0CloseInProgress = false; + IsEnabled = true; + } + } + } +} From 00de5f3bc493eb0ad14daa43d3348b438e951eaa Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 09:03:54 +0700 Subject: [PATCH 38/51] test: lock P0 responsiveness and TotPF report-only regressions --- .../P0LifecycleAndTotPfRegressionTests.cs | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs diff --git a/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs b/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs new file mode 100644 index 000000000..46594be66 --- /dev/null +++ b/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs @@ -0,0 +1,70 @@ +namespace ARSAS.Tests; + +public sealed class P0LifecycleAndTotPfRegressionTests +{ + [Fact] + public void MainWindow_RuntimeFacade_OffloadsLifecycleAndSerializesPerIedOnly() + { + var facade = Read("Services/UiResponsiveIec61850MonitorRuntimeFacade.cs"); + var main = Read("MainWindow.xaml.cs"); + + Assert.Contains("namespace ArIED61850Tester;", facade, StringComparison.Ordinal); + Assert.Contains("public sealed class Iec61850MonitorRuntime", facade, StringComparison.Ordinal); + Assert.Contains("Services.Iec61850MonitorRuntime _inner", facade, StringComparison.Ordinal); + Assert.Contains("ConcurrentDictionary _deviceLifecycleGates", facade, StringComparison.Ordinal); + Assert.Contains("Task.Run(operation, CancellationToken.None)", facade, StringComparison.Ordinal); + Assert.Contains("RunDeviceLifecycleAsync", facade, StringComparison.Ordinal); + Assert.Contains("DisposeBudget = TimeSpan.FromSeconds(3)", facade, StringComparison.Ordinal); + + // MainWindow intentionally uses the unqualified type from its own namespace, so the + // responsive facade is the UI boundary without altering protocol/runtime ownership. + Assert.Contains("private readonly Iec61850MonitorRuntime _runtime = new();", main, StringComparison.Ordinal); + } + + [Fact] + public void FatWindow_Close_DoesNotSealJournalsOrSaveOnDispatcher() + { + var lifecycle = Read("IoListTestingWindow.P0Lifecycle.cs"); + + Assert.Contains("Closing -= Window_Closing", lifecycle, StringComparison.Ordinal); + Assert.Contains("Closing += P0Window_Closing", lifecycle, StringComparison.Ordinal); + Assert.Contains("await Task.Run(() =>", lifecycle, StringComparison.Ordinal); + Assert.Contains("Session.StopAll", lifecycle, StringComparison.Ordinal); + Assert.Contains("await Task.Run(Storage.SaveNow)", lifecycle, StringComparison.Ordinal); + Assert.Contains("e.Cancel = true", lifecycle, StringComparison.Ordinal); + } + + [Fact] + public void StaticReport_TotPf_UsesExactSemanticEngineAuthorityWithoutMmsFallback() + { + var engineLock = Read("engines/ARIEC61850.lock.json"); + var semanticBridge = Read("Services/NativeIec61850Client.SemanticReporting.cs"); + var runtime = Read("Services/Iec61850MonitorRuntime.cs"); + var projection = Read("Services/StaticDataSetReportProjectionAccumulator.cs"); + + Assert.Contains("\"sourcePullRequest\": 111", engineLock, StringComparison.Ordinal); + Assert.Contains("69bfe70e2c779c7e8268af087bd1a3a38986c0fc", engineLock, StringComparison.Ordinal); + Assert.Contains("MmsSemanticReportValueProjector.Project", semanticBridge, StringComparison.Ordinal); + Assert.Contains("session.StaticReportProjection.Project", runtime, StringComparison.Ordinal); + Assert.Contains("MMS process fallback is disabled", runtime, StringComparison.Ordinal); + Assert.Contains(".mag.f", projection, StringComparison.OrdinalIgnoreCase); + Assert.Contains(".instmag.f", projection, StringComparison.OrdinalIgnoreCase); + } + + private static string Read(string relativePath) + => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + + 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); + } +} From 90c4cca5a4d6eb881b0d33381ca5df848f97fae7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 09:07:36 +0700 Subject: [PATCH 39/51] fix: install FAT P0 close guard without duplicate lifecycle override --- IoListTestingWindow.P0Lifecycle.cs | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/IoListTestingWindow.P0Lifecycle.cs b/IoListTestingWindow.P0Lifecycle.cs index 6ab762a01..46aadb7f5 100644 --- a/IoListTestingWindow.P0Lifecycle.cs +++ b/IoListTestingWindow.P0Lifecycle.cs @@ -14,12 +14,36 @@ namespace ArIED61850Tester; /// public partial class IoListTestingWindow { + private static readonly bool P0LifecycleRegistered = RegisterP0Lifecycle(); + + private bool _p0LifecycleInstalled; private bool _p0CloseInProgress; private bool _p0AllowClose; - protected override void OnSourceInitialized(EventArgs e) + private static bool RegisterP0Lifecycle() { - base.OnSourceInitialized(e); + // Avoid another Window lifecycle override in this already heavily split partial class. + // Loaded is a routed event, so one class handler can install the P0 Closing handler on + // every FAT workspace instance after XAML has wired its legacy Window_Closing handler. + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(P0Lifecycle_Loaded)); + return true; + } + + private static void P0Lifecycle_Loaded(object sender, RoutedEventArgs e) + { + if (sender is IoListTestingWindow window) + window.InstallP0Lifecycle(); + } + + private void InstallP0Lifecycle() + { + if (_p0LifecycleInstalled) + return; + + _p0LifecycleInstalled = true; // XAML wires Window_Closing during InitializeComponent. Replace only that lifecycle // edge; all existing OnClosed cleanup remains intact. From 04684291057a3f1d1cc7073dbc0dca0f64f88cf8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 09:37:34 +0700 Subject: [PATCH 40/51] P0: make stop preempt hung IEC61850 lifecycle work --- ...iResponsiveIec61850MonitorRuntimeFacade.cs | 222 +++++++++++++----- 1 file changed, 169 insertions(+), 53 deletions(-) diff --git a/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs b/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs index 34bb5274f..55c998ab8 100644 --- a/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs +++ b/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs @@ -7,23 +7,32 @@ namespace ArIED61850Tester; /// /// UI-facing facade for the IEC 61850 runtime. /// -/// The protocol engine intentionally owns MMS serialization, report ownership and per-IED -/// session state. WPF must not, however, execute the synchronous prefix of a native/network -/// operation on the Dispatcher thread. Some vendor/OS socket and teardown paths can block -/// before their first asynchronous yield; when invoked directly from a Click/Closing handler -/// that makes the whole application appear hung even though the API returns Task. +/// Native/network calls must never execute their synchronous prefix on the WPF Dispatcher. +/// A Task-returning API is not sufficient protection because socket/vendor code may block +/// before the first asynchronous yield. /// -/// This facade is resolved by MainWindow from its own namespace (and wraps the Services -/// runtime explicitly). Every potentially blocking lifecycle/control operation starts on the -/// ThreadPool, while a per-device gate prevents Connect/Start/Stop/Disconnect races for the -/// same IED. Different IEDs remain independent. No polling/read fallback is introduced here. +/// Per IED, normal lifecycle/control operations remain serialized. Stop/Disconnect is a +/// deliberately pre-emptive lane: it cancels the active operation first and invokes the +/// runtime stop without waiting behind a hung Connect/Start gate. A cancelled/stale operation +/// is never allowed to report success to its caller afterwards. Different IEDs stay fully +/// independent. This facade changes lifecycle scheduling only; it does not add MMS polling, +/// dynamic DataSet writes, or any acquisition fallback. /// public sealed class Iec61850MonitorRuntime : IAsyncDisposable { private static readonly TimeSpan DisposeBudget = TimeSpan.FromSeconds(3); + private sealed class DeviceOperationSlot + { + public object SyncRoot { get; } = new(); + public SemaphoreSlim OperationGate { get; } = new(1, 1); + public SemaphoreSlim StopGate { get; } = new(1, 1); + public CancellationTokenSource? ActiveOperationCancellation { get; set; } + public long Generation { get; set; } + } + private readonly Services.Iec61850MonitorRuntime _inner = new(); - private readonly ConcurrentDictionary _deviceLifecycleGates = + private readonly ConcurrentDictionary _deviceSlots = new(StringComparer.OrdinalIgnoreCase); private int _disposeStarted; @@ -45,105 +54,194 @@ public Task> ConnectAndDiscoverAsync( Iec61850MonitorDevice device, CancellationToken cancellationToken, IProgress? progress = null) - => RunDeviceLifecycleAsync( + => RunDeviceOperationAsync( device?.DeviceId, cancellationToken, - () => _inner.ConnectAndDiscoverAsync(device, cancellationToken, progress)); + token => _inner.ConnectAndDiscoverAsync(device, token, progress)); public Task ConnectUsingCachedModelAsync( Iec61850MonitorDevice device, CancellationToken cancellationToken, IProgress? progress = null) - => RunDeviceLifecycleAsync( + => RunDeviceOperationAsync( device?.DeviceId, cancellationToken, - () => _inner.ConnectUsingCachedModelAsync(device, cancellationToken, progress)); + token => _inner.ConnectUsingCachedModelAsync(device, token, progress)); public Task> StartMonitoringAsync( Iec61850MonitorDevice device, IEnumerable selectedSignals, int pollingIntervalMs, CancellationToken cancellationToken) - => RunDeviceLifecycleAsync( + => RunDeviceOperationAsync( device?.DeviceId, cancellationToken, - () => _inner.StartMonitoringAsync(device, selectedSignals, pollingIntervalMs, cancellationToken)); + token => _inner.StartMonitoringAsync(device, selectedSignals, pollingIntervalMs, token)); public Task InspectControlAsync( string deviceId, SignalDefinition signal, CancellationToken cancellationToken) - => RunDeviceLifecycleAsync( + => RunDeviceOperationAsync( deviceId, cancellationToken, - () => _inner.InspectControlAsync(deviceId, signal, cancellationToken)); + token => _inner.InspectControlAsync(deviceId, signal, token)); public Task ExecuteControlAsync( string deviceId, Iec61850ControlCommandRequest request, CancellationToken cancellationToken) - => RunDeviceLifecycleAsync( + => RunDeviceOperationAsync( deviceId, cancellationToken, - () => _inner.ExecuteControlAsync(deviceId, request, cancellationToken)); + token => _inner.ExecuteControlAsync(deviceId, request, token)); public HybridReportPhysicalValidationSnapshot CaptureHybridReportPhysicalValidation(string deviceId) => _inner.CaptureHybridReportPhysicalValidation(deviceId); public Task StopMonitoringAsync(string deviceId) - => RunDeviceLifecycleAsync( - deviceId, - CancellationToken.None, - () => _inner.StopMonitoringAsync(deviceId)); + => RunPreemptiveStopAsync(deviceId, () => _inner.StopMonitoringAsync(deviceId)); public Task StopDeviceAsync(string deviceId) - => RunDeviceLifecycleAsync( - deviceId, - CancellationToken.None, - () => _inner.StopDeviceAsync(deviceId)); + => RunPreemptiveStopAsync(deviceId, () => _inner.StopDeviceAsync(deviceId)); - private async Task RunDeviceLifecycleAsync( + private async Task RunDeviceOperationAsync( string? deviceId, CancellationToken cancellationToken, - Func operation) + Func operation) { ThrowIfDisposing(); ArgumentNullException.ThrowIfNull(operation); - var key = NormalizeDeviceKey(deviceId); - var gate = _deviceLifecycleGates.GetOrAdd(key, static _ => new SemaphoreSlim(1, 1)); - await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + var slot = GetSlot(deviceId); + await slot.OperationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + + CancellationTokenSource linkedCancellation; + long generation; + lock (slot.SyncRoot) + { + linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + slot.ActiveOperationCancellation = linkedCancellation; + generation = ++slot.Generation; + } + try { - // Task-returning APIs are not automatically non-blocking. Invoke the complete - // operation from the ThreadPool so a blocking native prefix can never seize the - // WPF Dispatcher. Awaiting callers still receive the real completion/exception. - await Task.Run(operation, CancellationToken.None).ConfigureAwait(false); + await Task.Run( + async () => await operation(linkedCancellation.Token).ConfigureAwait(false), + CancellationToken.None) + .ConfigureAwait(false); + + // Stop/Disconnect can pre-empt this lane. Even if a native call ignored + // cancellation and eventually returned success, a pre-empted generation must + // not re-enter the UI as a successful stale Connect/Start operation. + linkedCancellation.Token.ThrowIfCancellationRequested(); + lock (slot.SyncRoot) + { + if (slot.Generation != generation) + throw new OperationCanceledException("IEC 61850 lifecycle operation was superseded."); + } } finally { - gate.Release(); + lock (slot.SyncRoot) + { + if (slot.Generation == generation && + ReferenceEquals(slot.ActiveOperationCancellation, linkedCancellation)) + { + slot.ActiveOperationCancellation = null; + } + } + linkedCancellation.Dispose(); + slot.OperationGate.Release(); } } - private async Task RunDeviceLifecycleAsync( + private async Task RunDeviceOperationAsync( string? deviceId, CancellationToken cancellationToken, - Func> operation) + Func> operation) { ThrowIfDisposing(); ArgumentNullException.ThrowIfNull(operation); - var key = NormalizeDeviceKey(deviceId); - var gate = _deviceLifecycleGates.GetOrAdd(key, static _ => new SemaphoreSlim(1, 1)); - await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + var slot = GetSlot(deviceId); + await slot.OperationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + + CancellationTokenSource linkedCancellation; + long generation; + lock (slot.SyncRoot) + { + linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + slot.ActiveOperationCancellation = linkedCancellation; + generation = ++slot.Generation; + } + + try + { + var result = await Task.Run( + async () => await operation(linkedCancellation.Token).ConfigureAwait(false), + CancellationToken.None) + .ConfigureAwait(false); + + linkedCancellation.Token.ThrowIfCancellationRequested(); + lock (slot.SyncRoot) + { + if (slot.Generation != generation) + throw new OperationCanceledException("IEC 61850 lifecycle operation was superseded."); + } + return result; + } + finally + { + lock (slot.SyncRoot) + { + if (slot.Generation == generation && + ReferenceEquals(slot.ActiveOperationCancellation, linkedCancellation)) + { + slot.ActiveOperationCancellation = null; + } + } + linkedCancellation.Dispose(); + slot.OperationGate.Release(); + } + } + + private async Task RunPreemptiveStopAsync(string? deviceId, Func stopOperation) + { + ThrowIfDisposing(); + ArgumentNullException.ThrowIfNull(stopOperation); + + var slot = GetSlot(deviceId); + CancellationTokenSource? activeCancellation; + lock (slot.SyncRoot) + { + activeCancellation = slot.ActiveOperationCancellation; + ++slot.Generation; + } + + // Cancellation is intentionally issued before StopGate and, critically, without + // waiting for OperationGate. This is the escape path when Connect/Start is hung. try { - return await Task.Run(operation, CancellationToken.None).ConfigureAwait(false); + activeCancellation?.Cancel(); + } + catch (ObjectDisposedException) + { + // The active operation completed between snapshot and cancellation. + } + + await slot.StopGate.WaitAsync().ConfigureAwait(false); + try + { + await Task.Run( + async () => await stopOperation().ConfigureAwait(false), + CancellationToken.None) + .ConfigureAwait(false); } finally { - gate.Release(); + slot.StopGate.Release(); } } @@ -152,9 +250,24 @@ public async ValueTask DisposeAsync() if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) return; - // Dispose on a worker thread as well: a vendor/native close path is allowed to be - // slow, but it is never allowed to freeze the operator's window. MainWindow also has - // its own shutdown budget; this inner bound keeps the facade independently safe. + foreach (var slot in _deviceSlots.Values) + { + CancellationTokenSource? activeCancellation; + lock (slot.SyncRoot) + { + activeCancellation = slot.ActiveOperationCancellation; + ++slot.Generation; + } + try + { + activeCancellation?.Cancel(); + } + catch (ObjectDisposedException) + { + // Benign completion race during process teardown. + } + } + Task disposeTask; try { @@ -174,8 +287,7 @@ public async ValueTask DisposeAsync() } catch { - // Application shutdown is best-effort. The process must remain closable even - // when a native session reports a teardown failure. + // Shutdown is best-effort. Native teardown failure must not freeze WPF. } } else @@ -187,11 +299,15 @@ public async ValueTask DisposeAsync() TaskScheduler.Default); } - foreach (var gate in _deviceLifecycleGates.Values) - gate.Dispose(); - _deviceLifecycleGates.Clear(); + // Do not Dispose the per-device semaphores here. An uncooperative native operation + // can still unwind after the bounded shutdown budget and its finally block must be + // able to Release() safely. They become process-lifetime garbage with this facade. + _deviceSlots.Clear(); } + private DeviceOperationSlot GetSlot(string? deviceId) + => _deviceSlots.GetOrAdd(NormalizeDeviceKey(deviceId), static _ => new DeviceOperationSlot()); + private void ThrowIfDisposing() { if (Volatile.Read(ref _disposeStarted) != 0) From f6818967ac0db2682a490948e46f6178fcca63bd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 09:38:11 +0700 Subject: [PATCH 41/51] P0: keep FAT state transitions on dispatcher, offload persistence only --- IoListTestingWindow.P0Lifecycle.cs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/IoListTestingWindow.P0Lifecycle.cs b/IoListTestingWindow.P0Lifecycle.cs index 46aadb7f5..6bb04a16a 100644 --- a/IoListTestingWindow.P0Lifecycle.cs +++ b/IoListTestingWindow.P0Lifecycle.cs @@ -7,10 +7,11 @@ namespace ArIED61850Tester; /// /// P0 responsiveness guard for the FAT workspace lifecycle. /// -/// The legacy Closing handler sealed evidence journals and flushed persistence synchronously -/// on the WPF Dispatcher. Both operations can touch disk and, during a live session, cascade -/// into runtime teardown. That made Close look like an application hang. Keep confirmation on -/// the UI thread, then perform journal sealing and durable save on a worker thread. +/// The legacy Closing handler flushed workspace persistence synchronously on the WPF +/// Dispatcher. Keep confirmation and evidence-session state transitions on the Dispatcher +/// (the session controllers publish UI-bound PropertyChanged notifications there), then move +/// only the durable workspace save to a worker thread. Native IEC 61850 teardown is handled +/// independently by the responsive runtime facade. /// public partial class IoListTestingWindow { @@ -95,8 +96,11 @@ private async void P0Window_Closing(object? sender, CancelEventArgs e) { if (Session.HasActiveSessions) { - var stopAll = await Task.Run(() => - Session.StopAll("Workspace closed by operator; per-IED evidence journal sealed.")); + // Session.StopAll mutates controller/project state and raises UI-bound + // PropertyChanged notifications. Keep that state transition on Dispatcher; + // offloading the whole coordinator would create a cross-thread WPF defect. + var stopAll = Session.StopAll( + "Workspace closed by operator; per-IED evidence journal sealed."); if (!stopAll.Succeeded) { MessageBox.Show( @@ -109,6 +113,8 @@ private async void P0Window_Closing(object? sender, CancelEventArgs e) } } + // Persistence is pure durable I/O after the session state is sealed and is the + // portion that must not occupy the WPF Dispatcher. if (Storage != null) await Task.Run(Storage.SaveNow); From 97a7e31e95ec5e91baee91d623def97779c0b26c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 09:38:28 +0700 Subject: [PATCH 42/51] test: guard preemptive IEC lifecycle and WPF thread ownership --- .../P0LifecycleAndTotPfRegressionTests.cs | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs b/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs index 46594be66..3594d1ace 100644 --- a/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs +++ b/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs @@ -3,7 +3,7 @@ namespace ARSAS.Tests; public sealed class P0LifecycleAndTotPfRegressionTests { [Fact] - public void MainWindow_RuntimeFacade_OffloadsLifecycleAndSerializesPerIedOnly() + public void MainWindow_RuntimeFacade_OffloadsLifecycle_AndStopCanPreemptHungOperation() { var facade = Read("Services/UiResponsiveIec61850MonitorRuntimeFacade.cs"); var main = Read("MainWindow.xaml.cs"); @@ -11,10 +11,17 @@ public void MainWindow_RuntimeFacade_OffloadsLifecycleAndSerializesPerIedOnly() Assert.Contains("namespace ArIED61850Tester;", facade, StringComparison.Ordinal); Assert.Contains("public sealed class Iec61850MonitorRuntime", facade, StringComparison.Ordinal); Assert.Contains("Services.Iec61850MonitorRuntime _inner", facade, StringComparison.Ordinal); - Assert.Contains("ConcurrentDictionary _deviceLifecycleGates", facade, StringComparison.Ordinal); - Assert.Contains("Task.Run(operation, CancellationToken.None)", facade, StringComparison.Ordinal); - Assert.Contains("RunDeviceLifecycleAsync", facade, StringComparison.Ordinal); + Assert.Contains("ConcurrentDictionary _deviceSlots", facade, StringComparison.Ordinal); + Assert.Contains("SemaphoreSlim OperationGate", facade, StringComparison.Ordinal); + Assert.Contains("SemaphoreSlim StopGate", facade, StringComparison.Ordinal); + Assert.Contains("RunDeviceOperationAsync", facade, StringComparison.Ordinal); + Assert.Contains("RunPreemptiveStopAsync", facade, StringComparison.Ordinal); + Assert.Contains("activeCancellation?.Cancel()", facade, StringComparison.Ordinal); + Assert.Contains("without\n // waiting for OperationGate", facade, StringComparison.Ordinal); + Assert.Contains("linkedCancellation.Token.ThrowIfCancellationRequested()", facade, StringComparison.Ordinal); + Assert.Contains("slot.Generation != generation", facade, StringComparison.Ordinal); Assert.Contains("DisposeBudget = TimeSpan.FromSeconds(3)", facade, StringComparison.Ordinal); + Assert.Contains("Do not Dispose the per-device semaphores here", facade, StringComparison.Ordinal); // MainWindow intentionally uses the unqualified type from its own namespace, so the // responsive facade is the UI boundary without altering protocol/runtime ownership. @@ -22,14 +29,14 @@ public void MainWindow_RuntimeFacade_OffloadsLifecycleAndSerializesPerIedOnly() } [Fact] - public void FatWindow_Close_DoesNotSealJournalsOrSaveOnDispatcher() + public void FatWindow_Close_KeepsUiBoundSessionMutationOnDispatcher_AndOffloadsPersistence() { var lifecycle = Read("IoListTestingWindow.P0Lifecycle.cs"); Assert.Contains("Closing -= Window_Closing", lifecycle, StringComparison.Ordinal); Assert.Contains("Closing += P0Window_Closing", lifecycle, StringComparison.Ordinal); - Assert.Contains("await Task.Run(() =>", lifecycle, StringComparison.Ordinal); - Assert.Contains("Session.StopAll", lifecycle, StringComparison.Ordinal); + Assert.Contains("var stopAll = Session.StopAll(", lifecycle, StringComparison.Ordinal); + Assert.DoesNotContain("Task.Run(() =>\n Session.StopAll", lifecycle, StringComparison.Ordinal); Assert.Contains("await Task.Run(Storage.SaveNow)", lifecycle, StringComparison.Ordinal); Assert.Contains("e.Cancel = true", lifecycle, StringComparison.Ordinal); } From 0a7c6193d28a9db12b5ec8e7bec4a1325c59dce2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 09:39:38 +0700 Subject: [PATCH 43/51] P0: prevent reconnect racing preemptive IEC teardown --- ...iResponsiveIec61850MonitorRuntimeFacade.cs | 75 ++++++++++++------- 1 file changed, 47 insertions(+), 28 deletions(-) diff --git a/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs b/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs index 55c998ab8..2f499c89f 100644 --- a/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs +++ b/Services/UiResponsiveIec61850MonitorRuntimeFacade.cs @@ -116,17 +116,28 @@ private async Task RunDeviceOperationAsync( var slot = GetSlot(deviceId); await slot.OperationGate.WaitAsync(cancellationToken).ConfigureAwait(false); - CancellationTokenSource linkedCancellation; - long generation; - lock (slot.SyncRoot) - { - linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - slot.ActiveOperationCancellation = linkedCancellation; - generation = ++slot.Generation; - } - + CancellationTokenSource? linkedCancellation = null; + long generation = 0; try { + // A Stop that is already in progress owns StopGate. Waiting here prevents a new + // Connect/Start from racing the teardown. We hold StopGate only while publishing + // the new active-operation identity; never for the network operation itself. + await slot.StopGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + lock (slot.SyncRoot) + { + slot.ActiveOperationCancellation = linkedCancellation; + generation = ++slot.Generation; + } + } + finally + { + slot.StopGate.Release(); + } + await Task.Run( async () => await operation(linkedCancellation.Token).ConfigureAwait(false), CancellationToken.None) @@ -144,15 +155,15 @@ await Task.Run( } finally { - lock (slot.SyncRoot) + if (linkedCancellation != null) { - if (slot.Generation == generation && - ReferenceEquals(slot.ActiveOperationCancellation, linkedCancellation)) + lock (slot.SyncRoot) { - slot.ActiveOperationCancellation = null; + if (ReferenceEquals(slot.ActiveOperationCancellation, linkedCancellation)) + slot.ActiveOperationCancellation = null; } + linkedCancellation.Dispose(); } - linkedCancellation.Dispose(); slot.OperationGate.Release(); } } @@ -168,17 +179,25 @@ private async Task RunDeviceOperationAsync( var slot = GetSlot(deviceId); await slot.OperationGate.WaitAsync(cancellationToken).ConfigureAwait(false); - CancellationTokenSource linkedCancellation; - long generation; - lock (slot.SyncRoot) - { - linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - slot.ActiveOperationCancellation = linkedCancellation; - generation = ++slot.Generation; - } - + CancellationTokenSource? linkedCancellation = null; + long generation = 0; try { + await slot.StopGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + lock (slot.SyncRoot) + { + slot.ActiveOperationCancellation = linkedCancellation; + generation = ++slot.Generation; + } + } + finally + { + slot.StopGate.Release(); + } + var result = await Task.Run( async () => await operation(linkedCancellation.Token).ConfigureAwait(false), CancellationToken.None) @@ -194,15 +213,15 @@ private async Task RunDeviceOperationAsync( } finally { - lock (slot.SyncRoot) + if (linkedCancellation != null) { - if (slot.Generation == generation && - ReferenceEquals(slot.ActiveOperationCancellation, linkedCancellation)) + lock (slot.SyncRoot) { - slot.ActiveOperationCancellation = null; + if (ReferenceEquals(slot.ActiveOperationCancellation, linkedCancellation)) + slot.ActiveOperationCancellation = null; } + linkedCancellation.Dispose(); } - linkedCancellation.Dispose(); slot.OperationGate.Release(); } } From b310112b36f6d3d81bb480c0e7e5e323bfe2bda6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 10:27:58 +0700 Subject: [PATCH 44/51] Static DataSet: add deterministic configured-RCB report path --- ...veIec61850Client.StaticDataSetReporting.cs | 398 ++++++++++++++++++ 1 file changed, 398 insertions(+) create mode 100644 Services/NativeIec61850Client.StaticDataSetReporting.cs diff --git a/Services/NativeIec61850Client.StaticDataSetReporting.cs b/Services/NativeIec61850Client.StaticDataSetReporting.cs new file mode 100644 index 000000000..68cdcd9bb --- /dev/null +++ b/Services/NativeIec61850Client.StaticDataSetReporting.cs @@ -0,0 +1,398 @@ +using ArIED61850Tester.Models; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +/// +/// Deterministic acquisition path used only by Static DataSet report-only mode. +/// +/// Static mode already has complete configuration authority in the opened/live SCL model: +/// exact DataSet membership and configured RCB -> DataSet bindings. Do not run those facts +/// through the adaptive Hybrid acquisition planner. The live association is used only to +/// verify that the exact configured RCB and exact DataSet directory exist, then ARIEC's +/// persistent monitor installs the InformationReport receiver, enables RptEna and requests +/// GI. No dynamic DataSet write and no cyclic process-value MMS read is permitted here. +/// +public sealed partial class NativeIec61850Client +{ + private readonly Dictionary _deterministicStaticSubscriptions = + new(StringComparer.OrdinalIgnoreCase); + + public async Task BuildStaticDataSetReportPlansAsync( + Iec61850MonitorDevice device, + IReadOnlyCollection points, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(points); + cancellationToken.ThrowIfCancellationRequested(); + + _deterministicStaticSubscriptions.Clear(); + ResetSemanticReportProjectionContext(); + + var model = device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel; + if (model is null) + { + return StaticPlanningUnavailable( + points, + "Static DataSet report-only requires an opened/live SCL model with exact DataSet and RCB configuration."); + } + + if (!_session.IsMmsInitiated) + { + return StaticPlanningUnavailable( + points, + $"Static DataSet report-only requires an initiated MMS association. Current state: {_session.State}."); + } + + SetSemanticReportProjectionAuthority(model); + + // Fresh report discovery is verification, not permission policy. In particular we + // deliberately do NOT classify a configured BRCB through the Hybrid availability + // confidence gate. Some perfectly usable servers expose RptEna and DatSet but omit + // enough reservation metadata for that adaptive gate to call the RCB 'Available'. + var discovery = await EnsureDiscoveryForReportingAsync(cancellationToken).ConfigureAwait(false); + if (discovery is null) + { + return StaticPlanningUnavailable( + points, + string.IsNullOrWhiteSpace(LastErrorMessage) + ? "Fresh report discovery was unavailable." + : LastErrorMessage); + } + + var reportPlans = new List(); + var warnings = new List(); + var coveredPointKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + + var selectedByDataSet = points + .Where(point => !string.IsNullOrWhiteSpace(point.DataSetReference)) + .GroupBy(point => NormalizeStaticReference(point.DataSetReference), StringComparer.OrdinalIgnoreCase) + .Where(group => !string.IsNullOrWhiteSpace(group.Key)) + .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + foreach (var dataSetGroup in selectedByDataSet) + { + cancellationToken.ThrowIfCancellationRequested(); + + var configuredReports = model.ReportControls + .Where(report => SameStaticReference(report.DataSetReference, dataSetGroup.Key)) + .OrderByDescending(report => report.Buffered) + .ThenBy(report => report.Reference, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (configuredReports.Length == 0) + { + warnings.Add( + $"{dataSetGroup.First().DataSetReference}: no configured BRCB/URCB in the SCL model; {dataSetGroup.Count()} selected point(s) remain explicitly unavailable. No MMS process polling was substituted."); + continue; + } + + if (configuredReports.Length > 1) + { + warnings.Add( + $"{dataSetGroup.First().DataSetReference}: {configuredReports.Length} configured RCBs reference this DataSet; deterministic Static mode selected {configuredReports[0].Reference} (BRCB preferred, then literal reference order)." ); + } + + var configured = configuredReports[0]; + var liveCandidates = discovery.ReportInventory.ReportControls + .Where(candidate => SameStaticReference(candidate.Reference, configured.Reference)) + .ToArray(); + + if (liveCandidates.Length != 1) + { + warnings.Add( + $"{configured.Reference}: configured RCB was proven by SCL but exact live MMS discovery returned {liveCandidates.Length} matching RCB object(s). Static mode refused to guess and did not poll process values."); + continue; + } + + var liveRcb = CloneReportControlForPlanning(liveCandidates[0]); + var dataSetReference = dataSetGroup.First().DataSetReference.Trim(); + + if (!string.IsNullOrWhiteSpace(liveRcb.DataSetReference) && + !SameStaticReference(liveRcb.DataSetReference, dataSetReference)) + { + warnings.Add( + $"{configured.Reference}: SCL binds {dataSetReference}, but live DatSet reports {liveRcb.DataSetReference}. Static mode refused the mismatch instead of guessing or polling."); + continue; + } + + // Missing live DatSet text is not treated as a reason to discard correct SCL + // configuration. The exact DataSet directory below is still required and is the + // ordered mapping authority for InformationReport values. + if (string.IsNullOrWhiteSpace(liveRcb.DataSetReference)) + liveRcb.DataSetReference = dataSetReference; + + var directories = await RunMmsOperationAsync( + () => _session.GetDataSetDirectoriesAsync( + new[] { dataSetReference }, + discovery.IedDirectory, + cancellationToken), + cancellationToken).ConfigureAwait(false); + var directory = directories.SingleOrDefault(result => + result.IsSuccess && SameStaticReference(result.DataSetReference, dataSetReference)); + + if (directory is null || directory.Members.Count == 0) + { + var detail = directories.FirstOrDefault()?.Message ?? "no directory response"; + warnings.Add( + $"{dataSetReference}: live DataSet directory could not prove an ordered non-empty member list ({detail}). RCB was not armed because report-index mapping would be unsafe; MMS process polling remains disabled."); + continue; + } + + var bindings = dataSetGroup + .GroupBy(point => point.PointKey, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .OrderBy(point => point.IecReference, StringComparer.OrdinalIgnoreCase) + .ToList(); + if (bindings.Count == 0) + continue; + + var plan = new ReportControlPlan + { + RelayId = device.DeviceId, + RelayName = device.Name, + RelayIpAddress = device.IpAddress, + IedName = device.Name, + ReportControlReference = configured.Reference, + DataSetReference = dataSetReference, + Mode = "Static DataSet • deterministic configured RCB", + AllowDynamicDataSetWrites = false, + Buffered = configured.Buffered, + ReportId = liveRcb.ReportId, + IntegrityPeriodMs = ParseStaticInteger(liveRcb.IntegrityPeriodMs), + TriggerOptions = liveRcb.TriggerOptions, + OptionalFields = liveRcb.OptionalFields, + Status = "Deterministic static report planned", + IsEngineAuthoritative = true, + EngineAcquisitionKind = configured.Buffered ? "StaticBrcb" : "StaticUrcb", + Bindings = bindings + }; + + var subscriptionWarnings = new List(); + if (string.IsNullOrWhiteSpace(liveCandidates[0].DataSetReference)) + { + subscriptionWarnings.Add( + "Live RCB DatSet text was not returned; exact SCL RCB->DataSet configuration plus the successfully read live DataSet directory are the deterministic authority."); + } + + var subscription = new ArMms.MmsReportSubscriptionPlan + { + Mode = ArMms.MmsReportSubscriptionPlanMode.StaticDataSet, + Status = ArMms.MmsReportSubscriptionPlanStatus.ReadyRequiresWrite, + ReportControl = liveRcb, + DataSetReference = dataSetReference, + Members = directory.Members, + DynamicPoints = Array.Empty(), + Steps = new[] + { + $"Verify exact configured RCB {configured.Reference} exists on the live association.", + $"Use exact ordered live DataSet directory {dataSetReference} ({directory.Members.Count} members).", + "Install InformationReport receiver before enabling the RCB.", + "Write RptEna=true, then request GI=true.", + "Map report values by ordered DataSet member index; never substitute cyclic MMS process reads." + }, + Warnings = subscriptionWarnings + }; + + _deterministicStaticSubscriptions[plan.PlanId] = subscription; + reportPlans.Add(plan); + foreach (var binding in bindings) + coveredPointKeys.Add(binding.PointKey); + } + + var uncovered = points + .Where(point => !coveredPointKeys.Contains(point.PointKey)) + .Select(point => point.PointKey) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + var staticBrcb = reportPlans.Where(plan => plan.Buffered).Sum(plan => plan.BindingCount); + var staticUrcb = reportPlans.Where(plan => !plan.Buffered).Sum(plan => plan.BindingCount); + + return new NativeHybridReportPlanningResult + { + IsAuthoritative = true, + Authority = "Deterministic Static DataSet configured-RCB path", + Status = reportPlans.Count > 0 ? "StaticReportReady" : "StaticReportingUnavailable", + Summary = reportPlans.Count > 0 + ? $"Deterministic Static DataSet path prepared {reportPlans.Count} configured RCB plan(s), covering {coveredPointKeys.Count}/{points.Count} selected point(s). Hybrid planning, dynamic DataSet writes and cyclic MMS process polling were bypassed." + : $"No configured Static DataSet RCB could be armed safely for {points.Count} selected point(s). Hybrid planning and cyclic MMS process polling were not used.", + ReportPlans = reportPlans, + PollingPointKeys = Array.Empty(), + UncoveredPointKeys = uncovered, + UnmappedPointKeys = Array.Empty(), + PointAttemptEvidence = Array.Empty(), + Warnings = warnings, + RequestedPointCount = points.Count, + CatalogMappedPointCount = points.Count, + StaticBrcbSignalCount = staticBrcb, + StaticUrcbSignalCount = staticUrcb, + DynamicBrcbSignalCount = 0, + DynamicUrcbSignalCount = 0, + PollingFallbackSignalCount = 0, + UncoveredSignalCount = uncovered.Length + }; + } + + public async Task StartStaticDataSetReportMonitorAsync( + ReportControlPlan plan, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(plan); + cancellationToken.ThrowIfCancellationRequested(); + + if (!_deterministicStaticSubscriptions.TryGetValue(plan.PlanId, out var subscription)) + { + return new NativeReportMonitorStartResult + { + IsSuccess = false, + PlanId = plan.PlanId, + Message = "Deterministic Static DataSet subscription evidence is missing. RCB was not armed; MMS process polling remains disabled.", + FailureReason = "StaticSubscriptionEvidenceMissing" + }; + } + + if (_reportMonitorSessions.ContainsKey(plan.PlanId)) + { + return new NativeReportMonitorStartResult + { + IsSuccess = true, + PlanId = plan.PlanId, + Message = $"Deterministic Static DataSet monitor already active for {plan.DisplayReference}.", + SubscriptionSummary = subscription.Summary, + MemberCount = subscription.Members.Count, + ReportControlReference = plan.ReportControlReference, + DataSetReference = plan.DataSetReference, + AcquisitionLabel = $"Static DataSet: {plan.EngineAcquisitionKind}", + CoveredReferences = _reportMonitorCoverage.TryGetValue(plan.PlanId, out var existingCoverage) + ? existingCoverage + : Array.Empty() + }; + } + + if (!_session.IsMmsInitiated) + { + return new NativeReportMonitorStartResult + { + IsSuccess = false, + PlanId = plan.PlanId, + Message = $"Deterministic Static DataSet monitor requires an initiated MMS association. Current state: {_session.State}.", + SubscriptionSummary = subscription.Summary, + MemberCount = subscription.Members.Count, + FailureReason = "TransportUnavailable" + }; + } + + var discovery = await EnsureDiscoveryForReportingAsync(cancellationToken).ConfigureAwait(false); + if (discovery is null) + { + return new NativeReportMonitorStartResult + { + IsSuccess = false, + PlanId = plan.PlanId, + Message = string.IsNullOrWhiteSpace(LastErrorMessage) ? "Fresh report discovery unavailable." : LastErrorMessage, + SubscriptionSummary = subscription.Summary, + MemberCount = subscription.Members.Count, + FailureReason = "FreshReportDiscoveryUnavailable" + }; + } + + var coveredReferences = ExtractSubscriptionMemberReferences(subscription.Members); + var attempt = await RunMmsOperationAsync( + () => _session.StartPersistentReportMonitorWithAttemptEvidenceAsync( + subscription, + triggerGeneralInterrogation: true, + deleteDynamicDataSetOnStop: false, + discovery.IedDirectory, + cancellationToken), + cancellationToken).ConfigureAwait(false); + var start = attempt.StartResult; + var warnings = start.Warnings + .Concat(subscription.Warnings) + .Concat(attempt.CleanupWarnings) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (!attempt.IsSuccess || start.Session is null) + { + return new NativeReportMonitorStartResult + { + IsSuccess = false, + PlanId = plan.PlanId, + Message = $"Deterministic Static DataSet activation failed for {plan.DisplayReference}: {start.Message}", + SubscriptionSummary = subscription.Summary, + MemberCount = subscription.Members.Count, + WriteStepCount = start.WriteSteps.Count, + UsedDynamicDataSet = false, + DynamicAttempted = false, + DynamicAttemptState = "NotApplicable", + FailureReason = attempt.FailureReason.ToString(), + CleanupAttempted = attempt.CleanupAttempted, + CleanupSucceeded = attempt.CleanupSucceeded, + ReportControlReference = plan.ReportControlReference, + DataSetReference = plan.DataSetReference, + CoveredReferences = coveredReferences, + Warnings = warnings + }; + } + + if (!string.IsNullOrWhiteSpace(start.Session.ReportControl.Reference)) + plan.ReportControlReference = start.Session.ReportControl.Reference; + if (!string.IsNullOrWhiteSpace(start.Session.Plan.DataSetReference)) + plan.DataSetReference = start.Session.Plan.DataSetReference; + + _reportMonitorSessions[plan.PlanId] = start.Session; + _reportMonitorCoverage[plan.PlanId] = coveredReferences; + + return new NativeReportMonitorStartResult + { + IsSuccess = true, + PlanId = plan.PlanId, + Message = $"Deterministic Static DataSet {plan.EngineAcquisitionKind} active. {start.Message}", + SubscriptionSummary = subscription.Summary, + MemberCount = subscription.Members.Count, + WriteStepCount = start.WriteSteps.Count, + UsedDynamicDataSet = false, + DynamicAttempted = false, + DynamicAttemptState = "NotApplicable", + ReportControlReference = plan.ReportControlReference, + DataSetReference = plan.DataSetReference, + AcquisitionLabel = $"Static DataSet: {plan.EngineAcquisitionKind}", + CoveredReferences = coveredReferences, + Warnings = warnings + }; + } + + private static NativeHybridReportPlanningResult StaticPlanningUnavailable( + IReadOnlyCollection points, + string reason) + => new() + { + IsAuthoritative = true, + Authority = "Deterministic Static DataSet configured-RCB path", + Status = "StaticReportingUnavailable", + Summary = reason + " Cyclic MMS process polling was not enabled.", + RequestedPointCount = points.Count, + CatalogMappedPointCount = points.Count, + PollingPointKeys = Array.Empty(), + UncoveredPointKeys = points.Select(point => point.PointKey).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), + UnmappedPointKeys = Array.Empty(), + PointAttemptEvidence = Array.Empty(), + Warnings = new[] { reason }, + PollingFallbackSignalCount = 0, + UncoveredSignalCount = points.Count + }; + + private static bool SameStaticReference(string? left, string? right) + => string.Equals( + NormalizeStaticReference(left), + NormalizeStaticReference(right), + StringComparison.OrdinalIgnoreCase); + + private static string NormalizeStaticReference(string? reference) + => (reference ?? string.Empty).Trim().Replace('$', '.'); + + private static int ParseStaticInteger(string? value) + => int.TryParse(value, out var parsed) && parsed > 0 ? parsed : 0; +} From 2dd6fbc14e8c9508c98f09768c7f5644f3f5eba4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 10:28:14 +0700 Subject: [PATCH 45/51] test: lock deterministic Static DataSet reporting path --- ...ministicStaticReportPathRegressionTests.cs | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs diff --git a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs new file mode 100644 index 000000000..1fe03fe8c --- /dev/null +++ b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs @@ -0,0 +1,77 @@ +namespace ARSAS.Tests; + +public sealed class DeterministicStaticReportPathRegressionTests +{ + [Fact] + public void StaticPath_BypassesAdaptiveHybridPlannerAndPolling() + { + var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs"); + + Assert.Contains("Deterministic Static DataSet configured-RCB path", source, StringComparison.Ordinal); + Assert.Contains("model.ReportControls", source, StringComparison.Ordinal); + Assert.Contains("discovery.ReportInventory.ReportControls", source, StringComparison.Ordinal); + Assert.Contains("GetDataSetDirectoriesAsync", source, StringComparison.Ordinal); + Assert.Contains("MmsReportSubscriptionPlanStatus.ReadyRequiresWrite", source, StringComparison.Ordinal); + Assert.Contains("triggerGeneralInterrogation: true", source, StringComparison.Ordinal); + Assert.Contains("deleteDynamicDataSetOnStop: false", source, StringComparison.Ordinal); + Assert.Contains("PollingPointKeys = Array.Empty()", source, StringComparison.Ordinal); + Assert.Contains("PollingFallbackSignalCount = 0", source, StringComparison.Ordinal); + + Assert.DoesNotContain("MmsCapabilityAwareHybridReportAcquisitionPlanner", source, StringComparison.Ordinal); + Assert.DoesNotContain("BuildDynamicPlan", source, StringComparison.Ordinal); + Assert.DoesNotContain("DefineNamedVariableList", source, StringComparison.Ordinal); + Assert.DoesNotContain("ReadValueAsync", source, StringComparison.Ordinal); + } + + [Fact] + public void StaticPath_RequiresExactConfiguredRcbAndOrderedLiveDataSetDirectory() + { + var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs"); + + Assert.Contains("SameStaticReference(report.DataSetReference, dataSetGroup.Key)", source, StringComparison.Ordinal); + Assert.Contains("SameStaticReference(candidate.Reference, configured.Reference)", source, StringComparison.Ordinal); + Assert.Contains("live DataSet directory could not prove an ordered non-empty member list", source, StringComparison.Ordinal); + Assert.Contains("directory.Members", source, StringComparison.Ordinal); + Assert.Contains("No MMS process polling was substituted", source, StringComparison.Ordinal); + Assert.Contains("SCL binds", source, StringComparison.Ordinal); + Assert.Contains("live DatSet reports", source, StringComparison.Ordinal); + } + + [Fact] + public void HybridEntryPoints_RouteStaticModeToDeterministicPath() + { + var hybrid = Read("Services/NativeIec61850Client.HybridReporting.cs"); + + Assert.Contains("Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device.DeviceId)", hybrid, StringComparison.Ordinal); + Assert.Contains("BuildStaticDataSetReportPlansAsync", hybrid, StringComparison.Ordinal); + Assert.Contains("_deterministicStaticSubscriptions.ContainsKey(plan.PlanId)", hybrid, StringComparison.Ordinal); + Assert.Contains("StartStaticDataSetReportMonitorAsync", hybrid, StringComparison.Ordinal); + } + + [Fact] + public void ManualHybridPlanner_RemainsAvailableOutsideStaticRoute() + { + var hybrid = Read("Services/NativeIec61850Client.HybridReporting.cs"); + + Assert.Contains("MmsCapabilityAwareHybridReportAcquisitionPlanner.Build", hybrid, StringComparison.Ordinal); + Assert.Contains("AllowDynamicBrcb", hybrid, StringComparison.Ordinal); + Assert.Contains("AllowPollingFallback = true", hybrid, StringComparison.Ordinal); + } + + private static string Read(string relativePath) + => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + + 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); + } +} From 7181d283d8ee0eb1a698c491d445f4bf0c2691bc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 10:28:35 +0700 Subject: [PATCH 46/51] chore: stage deterministic Static DataSet route patch --- .../patch-pr255-deterministic-static.yml | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/patch-pr255-deterministic-static.yml diff --git a/.github/workflows/patch-pr255-deterministic-static.yml b/.github/workflows/patch-pr255-deterministic-static.yml new file mode 100644 index 000000000..1266c2f0b --- /dev/null +++ b/.github/workflows/patch-pr255-deterministic-static.yml @@ -0,0 +1,48 @@ +name: Patch PR255 deterministic Static DataSet route + +on: + push: + branches: + - fix/static-dataset-report-projection-parity + paths: + - .github/workflows/patch-pr255-deterministic-static.yml + +permissions: + contents: write + +jobs: + patch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/static-dataset-report-projection-parity + fetch-depth: 0 + - name: Route Static mode around Hybrid planner + shell: python + run: | + from pathlib import Path + path = Path('Services/NativeIec61850Client.HybridReporting.cs') + text = path.read_text(encoding='utf-8') + + before_build = ''' ArgumentNullException.ThrowIfNull(device);\n ArgumentNullException.ThrowIfNull(points);\n cancellationToken.ThrowIfCancellationRequested();\n\n _authoritativeHybridSubscriptions.Clear();''' + after_build = ''' ArgumentNullException.ThrowIfNull(device);\n ArgumentNullException.ThrowIfNull(points);\n cancellationToken.ThrowIfCancellationRequested();\n\n // Static DataSet is deliberately deterministic: exact configured SCL RCB +\n // exact live RCB object + ordered live DataSet directory -> RptEna + GI. Do not\n // send Static mode through adaptive Hybrid availability/capability policy.\n if (Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device.DeviceId))\n {\n return await BuildStaticDataSetReportPlansAsync(\n device,\n points,\n cancellationToken).ConfigureAwait(false);\n }\n\n _authoritativeHybridSubscriptions.Clear();''' + if before_build not in text: + raise SystemExit('BuildHybrid route anchor not found') + text = text.replace(before_build, after_build, 1) + + before_start = ''' ArgumentNullException.ThrowIfNull(plan);\n cancellationToken.ThrowIfCancellationRequested();\n\n if (!plan.IsEngineAuthoritative)''' + after_start = ''' ArgumentNullException.ThrowIfNull(plan);\n cancellationToken.ThrowIfCancellationRequested();\n\n // Deterministic Static plans are executed directly. Hybrid revalidation can\n // otherwise discard a valid configured RCB merely because reservation metadata\n // is incomplete, which is exactly the field regression this path removes.\n if (_deterministicStaticSubscriptions.ContainsKey(plan.PlanId))\n return await StartStaticDataSetReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false);\n\n if (!plan.IsEngineAuthoritative)''' + if before_start not in text: + raise SystemExit('StartHybrid route anchor not found') + text = text.replace(before_start, after_start, 1) + path.write_text(text, encoding='utf-8') + - name: Commit route patch and remove staging workflow + shell: bash + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git rm .github/workflows/patch-pr255-deterministic-static.yml + git add Services/NativeIec61850Client.HybridReporting.cs + git commit -m "Static DataSet: bypass adaptive Hybrid planner" + git push origin HEAD:fix/static-dataset-report-projection-parity From 86a7593e500c46d434a6139294149b6f9f836940 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 03:28:44 +0000 Subject: [PATCH 47/51] Static DataSet: bypass adaptive Hybrid planner --- .../patch-pr255-deterministic-static.yml | 48 ------------------- .../NativeIec61850Client.HybridReporting.cs | 17 +++++++ 2 files changed, 17 insertions(+), 48 deletions(-) delete mode 100644 .github/workflows/patch-pr255-deterministic-static.yml diff --git a/.github/workflows/patch-pr255-deterministic-static.yml b/.github/workflows/patch-pr255-deterministic-static.yml deleted file mode 100644 index 1266c2f0b..000000000 --- a/.github/workflows/patch-pr255-deterministic-static.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Patch PR255 deterministic Static DataSet route - -on: - push: - branches: - - fix/static-dataset-report-projection-parity - paths: - - .github/workflows/patch-pr255-deterministic-static.yml - -permissions: - contents: write - -jobs: - patch: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/static-dataset-report-projection-parity - fetch-depth: 0 - - name: Route Static mode around Hybrid planner - shell: python - run: | - from pathlib import Path - path = Path('Services/NativeIec61850Client.HybridReporting.cs') - text = path.read_text(encoding='utf-8') - - before_build = ''' ArgumentNullException.ThrowIfNull(device);\n ArgumentNullException.ThrowIfNull(points);\n cancellationToken.ThrowIfCancellationRequested();\n\n _authoritativeHybridSubscriptions.Clear();''' - after_build = ''' ArgumentNullException.ThrowIfNull(device);\n ArgumentNullException.ThrowIfNull(points);\n cancellationToken.ThrowIfCancellationRequested();\n\n // Static DataSet is deliberately deterministic: exact configured SCL RCB +\n // exact live RCB object + ordered live DataSet directory -> RptEna + GI. Do not\n // send Static mode through adaptive Hybrid availability/capability policy.\n if (Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device.DeviceId))\n {\n return await BuildStaticDataSetReportPlansAsync(\n device,\n points,\n cancellationToken).ConfigureAwait(false);\n }\n\n _authoritativeHybridSubscriptions.Clear();''' - if before_build not in text: - raise SystemExit('BuildHybrid route anchor not found') - text = text.replace(before_build, after_build, 1) - - before_start = ''' ArgumentNullException.ThrowIfNull(plan);\n cancellationToken.ThrowIfCancellationRequested();\n\n if (!plan.IsEngineAuthoritative)''' - after_start = ''' ArgumentNullException.ThrowIfNull(plan);\n cancellationToken.ThrowIfCancellationRequested();\n\n // Deterministic Static plans are executed directly. Hybrid revalidation can\n // otherwise discard a valid configured RCB merely because reservation metadata\n // is incomplete, which is exactly the field regression this path removes.\n if (_deterministicStaticSubscriptions.ContainsKey(plan.PlanId))\n return await StartStaticDataSetReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false);\n\n if (!plan.IsEngineAuthoritative)''' - if before_start not in text: - raise SystemExit('StartHybrid route anchor not found') - text = text.replace(before_start, after_start, 1) - path.write_text(text, encoding='utf-8') - - name: Commit route patch and remove staging workflow - shell: bash - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git rm .github/workflows/patch-pr255-deterministic-static.yml - git add Services/NativeIec61850Client.HybridReporting.cs - git commit -m "Static DataSet: bypass adaptive Hybrid planner" - git push origin HEAD:fix/static-dataset-report-projection-parity diff --git a/Services/NativeIec61850Client.HybridReporting.cs b/Services/NativeIec61850Client.HybridReporting.cs index bcf9a5de5..f8b4d6107 100644 --- a/Services/NativeIec61850Client.HybridReporting.cs +++ b/Services/NativeIec61850Client.HybridReporting.cs @@ -45,6 +45,17 @@ public async Task BuildHybridReportPlansAsync( ArgumentNullException.ThrowIfNull(points); cancellationToken.ThrowIfCancellationRequested(); + // Static DataSet is deliberately deterministic: exact configured SCL RCB + + // exact live RCB object + ordered live DataSet directory -> RptEna + GI. Do not + // send Static mode through adaptive Hybrid availability/capability policy. + if (Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device.DeviceId)) + { + return await BuildStaticDataSetReportPlansAsync( + device, + points, + cancellationToken).ConfigureAwait(false); + } + _authoritativeHybridSubscriptions.Clear(); ResetSemanticReportProjectionContext(); @@ -356,6 +367,12 @@ public async Task StartHybridReportMonitorAsync( ArgumentNullException.ThrowIfNull(plan); cancellationToken.ThrowIfCancellationRequested(); + // Deterministic Static plans are executed directly. Hybrid revalidation can + // otherwise discard a valid configured RCB merely because reservation metadata + // is incomplete, which is exactly the field regression this path removes. + if (_deterministicStaticSubscriptions.ContainsKey(plan.PlanId)) + return await StartStaticDataSetReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false); + if (!plan.IsEngineAuthoritative) { return new NativeReportMonitorStartResult From 14999f170b35c3c3556551c75a8a6918444dec9d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 10:29:39 +0700 Subject: [PATCH 48/51] docs: lock deterministic configured-RCB Static path --- docs/static-dataset-report-only-contract.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/static-dataset-report-only-contract.md b/docs/static-dataset-report-only-contract.md index d203cbd0b..b9df6ab8e 100644 --- a/docs/static-dataset-report-only-contract.md +++ b/docs/static-dataset-report-only-contract.md @@ -13,6 +13,20 @@ This document is the acceptance boundary for ARSAS Static DataSet monitoring. Manual / Select Signals mode keeps the normal Hybrid acquisition behavior and is intentionally outside this contract. +## Deterministic configured-RCB path + +Static DataSet mode does not pass configured SCL reporting through the adaptive Hybrid acquisition planner. The deterministic sequence is: + +1. Take the exact DataSet membership and RCB -> DataSet binding from the opened/live SCL model. +2. Verify that the exact configured RCB object exists on the active MMS association. +3. Read the exact live DataSet directory to obtain a non-empty ordered member list. This is report-index mapping evidence, not process-value polling. +4. Reject an explicit SCL/live `DatSet` mismatch; never guess another RCB or DataSet. +5. Install the InformationReport receiver before enabling reporting. +6. Write `RptEna=true`, then request `GI=true` through the normal one-shot MMS control plane. +7. Map report values by ordered DataSet member index and project them through the exact semantic report schema. + +Missing reservation metadata is not allowed to cancel an otherwise exact configured static RCB before an actual enable attempt. Conversely, a missing exact RCB, unreadable/empty DataSet directory, or explicit SCL/live binding mismatch fails closed. None of these conditions enables cyclic MMS process-value reads. + ## Structured report values ARIEC may decode one structured Static DataSet member into exact semantic scalar descendants. ARSAS may reconstruct the parent DataSet row only when the live/SCL schema proves every required descendant identity. This projection must fail closed: no positional phase guessing, prefix/fuzzy matching, sibling substitution, or MMS fallback is allowed. @@ -26,6 +40,8 @@ A Static DataSet physical test passes this contract only when: 1. Live Signal Values contains the exact Static DataSet membership set rather than duplicate `cVal` / `instCVal` aliases. 2. The Acquisition column contains only configured static report acquisition (`StaticBrcb` / `StaticUrcb`) for resolved values; it contains no `MMS polling` rows. 3. General Interrogation can supply the initial report image for schema-safe reportable members. -4. Unsupported or ambiguous report projection remains explicitly Pending/unresolved instead of silently switching acquisition method. +4. A DataSet with no configured RCB remains explicitly unavailable instead of being sent through Hybrid planning or MMS polling. +5. Unsupported or ambiguous report projection remains explicitly Pending/unresolved instead of silently switching acquisition method. +6. Stop/start/reconnect and FAT attach/detach preserve the same report-only authority without freezing the WPF UI. -This keeps the Static DataSet workflow deterministic and prevents a readability fix from regressing the established report-only protocol contract. +This keeps the Static DataSet workflow deterministic and prevents a readability or feature change from regressing the established report-only protocol contract. From d903c852713af8fb6efe96cb35f6511763e20546 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 10:31:30 +0700 Subject: [PATCH 49/51] chore: stage Static mode registry call fix --- .../patch-pr255-static-mode-call.yml | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/patch-pr255-static-mode-call.yml diff --git a/.github/workflows/patch-pr255-static-mode-call.yml b/.github/workflows/patch-pr255-static-mode-call.yml new file mode 100644 index 000000000..12de06082 --- /dev/null +++ b/.github/workflows/patch-pr255-static-mode-call.yml @@ -0,0 +1,40 @@ +name: Patch PR255 Static mode registry call + +on: + push: + branches: + - fix/static-dataset-report-projection-parity + paths: + - .github/workflows/patch-pr255-static-mode-call.yml + +permissions: + contents: write + +jobs: + patch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/static-dataset-report-projection-parity + fetch-depth: 0 + - name: Fix Static mode registry call + shell: python + run: | + from pathlib import Path + path = Path('Services/NativeIec61850Client.HybridReporting.cs') + text = path.read_text(encoding='utf-8') + old = 'Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device.DeviceId)' + new = 'Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device)' + if text.count(old) != 1: + raise SystemExit(f'expected exactly one registry-call anchor, found {text.count(old)}') + path.write_text(text.replace(old, new, 1), encoding='utf-8') + - name: Commit fix and remove staging workflow + shell: bash + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git rm .github/workflows/patch-pr255-static-mode-call.yml + git add Services/NativeIec61850Client.HybridReporting.cs + git commit -m "fix: use device identity for Static mode registry" + git push origin HEAD:fix/static-dataset-report-projection-parity From f5cfca730f0908d6f98f2d8bceb7a1dfa9fa9998 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 03:31:40 +0000 Subject: [PATCH 50/51] fix: use device identity for Static mode registry --- .../patch-pr255-static-mode-call.yml | 40 ------------------- .../NativeIec61850Client.HybridReporting.cs | 2 +- 2 files changed, 1 insertion(+), 41 deletions(-) delete mode 100644 .github/workflows/patch-pr255-static-mode-call.yml diff --git a/.github/workflows/patch-pr255-static-mode-call.yml b/.github/workflows/patch-pr255-static-mode-call.yml deleted file mode 100644 index 12de06082..000000000 --- a/.github/workflows/patch-pr255-static-mode-call.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Patch PR255 Static mode registry call - -on: - push: - branches: - - fix/static-dataset-report-projection-parity - paths: - - .github/workflows/patch-pr255-static-mode-call.yml - -permissions: - contents: write - -jobs: - patch: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/static-dataset-report-projection-parity - fetch-depth: 0 - - name: Fix Static mode registry call - shell: python - run: | - from pathlib import Path - path = Path('Services/NativeIec61850Client.HybridReporting.cs') - text = path.read_text(encoding='utf-8') - old = 'Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device.DeviceId)' - new = 'Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device)' - if text.count(old) != 1: - raise SystemExit(f'expected exactly one registry-call anchor, found {text.count(old)}') - path.write_text(text.replace(old, new, 1), encoding='utf-8') - - name: Commit fix and remove staging workflow - shell: bash - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git rm .github/workflows/patch-pr255-static-mode-call.yml - git add Services/NativeIec61850Client.HybridReporting.cs - git commit -m "fix: use device identity for Static mode registry" - git push origin HEAD:fix/static-dataset-report-projection-parity diff --git a/Services/NativeIec61850Client.HybridReporting.cs b/Services/NativeIec61850Client.HybridReporting.cs index f8b4d6107..5c9750701 100644 --- a/Services/NativeIec61850Client.HybridReporting.cs +++ b/Services/NativeIec61850Client.HybridReporting.cs @@ -48,7 +48,7 @@ public async Task BuildHybridReportPlansAsync( // Static DataSet is deliberately deterministic: exact configured SCL RCB + // exact live RCB object + ordered live DataSet directory -> RptEna + GI. Do not // send Static mode through adaptive Hybrid availability/capability policy. - if (Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device.DeviceId)) + if (Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device)) { return await BuildStaticDataSetReportPlansAsync( device, From 0f7e6603bde5ebb34f13f34e1688974e57923efa Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 10:32:19 +0700 Subject: [PATCH 51/51] test: bind Static mode route to device identity --- .../DeterministicStaticReportPathRegressionTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs index 1fe03fe8c..af8ab324a 100644 --- a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs +++ b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs @@ -42,10 +42,11 @@ public void HybridEntryPoints_RouteStaticModeToDeterministicPath() { var hybrid = Read("Services/NativeIec61850Client.HybridReporting.cs"); - Assert.Contains("Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device.DeviceId)", hybrid, StringComparison.Ordinal); + Assert.Contains("Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device)", hybrid, StringComparison.Ordinal); Assert.Contains("BuildStaticDataSetReportPlansAsync", hybrid, StringComparison.Ordinal); Assert.Contains("_deterministicStaticSubscriptions.ContainsKey(plan.PlanId)", hybrid, StringComparison.Ordinal); Assert.Contains("StartStaticDataSetReportMonitorAsync", hybrid, StringComparison.Ordinal); + Assert.DoesNotContain("Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device.DeviceId)", hybrid, StringComparison.Ordinal); } [Fact]