feat(alarms): condition Quality tracks source connectivity (#477)
Part 9 ConditionType.Quality was never assigned; default(StatusCode)==Good so every native + scripted condition reported Good unconditionally — a comms-lost device still showed a healthy, inactive, Good condition (a wrong-VALUE bug, distinct from the null-value #473/#475). Clients (and HMIs bucketing on IsGood) could not tell "genuinely inactive" from "lost contact". Layer 1 — make Quality a real, plumbed field: - AlarmConditionSnapshot gains OpcUaQuality Quality (default Good). - MaterialiseAlarmCondition sets it (native BadWaitingForInitialData, scripted Good). - WriteAlarmCondition projects snapshot.Quality; the delta-gate gains a Quality member so a quality-bucket change fires a Part 9 event. Layer 2 — drive native quality from driver connectivity (a comms-lost driver emits no alarm transitions, and an alarm-bearing raw tag has no value variable, so quality can't come from either existing channel): - DriverInstanceActor Tells parent ConnectivityChanged on Connected/Reconnecting. - DriverHostActor fans it to every native condition the driver owns as OpcUaPublishActor.AlarmQualityUpdate (Good on connect, Bad on disconnect). - New dedicated IOpcUaAddressSpaceSink.WriteAlarmQuality sets ONLY Quality and fires only on a bucket change — never touches Active/Acked/Retain (an active alarm that loses comms stays active). Not a full-snapshot re-projection, so it can't clobber severity/message and works for a never-fired condition. Forwarded through DeferredAddressSpaceSink (F10b trap; auto-verified by the reflection forwarding guard). Ungated by redundancy role; no /alerts row. Scripted conditions stay Good; worst-of-input quality deferred to #478 (Layer 3). Tests: node-level (materialise/project/no-clobber/unknown-node no-op), NativeAlarmProjector, DriverInstanceActor connectivity emission, DriverHostActor fan-out, OpcUaPublishActor routing, and the wire-level guard (Condition_event_Quality_tracks_source_connectivity_on_the_wire) — RED-verified against a simulated pre-fix always-Good server. Existing DriverInstanceActor parent probes ignore the new ConnectivityChanged. Docs: docs/AlarmTracking.md §"Condition source-data Quality (#477)"; design doc docs/plans/2026-07-17-alarm-condition-quality-477-design.md.
This commit is contained in:
+74
@@ -31,6 +31,7 @@ public sealed class NativeAlarmEventIdentityFieldDeliveryTests
|
||||
{
|
||||
private const string ServerUri = "urn:OtOpcUa.AlarmEventIdentityFields";
|
||||
private const string ConditionClassServerUri = "urn:OtOpcUa.AlarmEventConditionClassFields";
|
||||
private const string QualityServerUri = "urn:OtOpcUa.AlarmEventQualityField";
|
||||
|
||||
private const string RawDeviceFolder = "pymodbus/plc";
|
||||
private const string RawAlarmPath = "pymodbus/plc/HR200";
|
||||
@@ -53,6 +54,11 @@ public sealed class NativeAlarmEventIdentityFieldDeliveryTests
|
||||
private const int ConditionClassNameIndex = 1;
|
||||
private const int ConditionClassMessageIndex = 2;
|
||||
|
||||
// #477 — indices in BuildQualityEventFilter's clause [Quality, Message]. Again SEPARATE from the load-bearing
|
||||
// ScadaBridge clause so its indices don't shift.
|
||||
private const int QualityIndex = 0;
|
||||
private const int QualityMessageIndex = 1;
|
||||
|
||||
/// <summary>A live native condition event delivers a populated EventType / SourceNode / SourceName to a
|
||||
/// Server-object subscriber using the standard BaseEventType select clause.</summary>
|
||||
[Fact]
|
||||
@@ -172,6 +178,63 @@ public sealed class NativeAlarmEventIdentityFieldDeliveryTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Issue #477 — the over-the-wire proof that a native condition's source-data Quality tracks its
|
||||
/// driver's connectivity. A comms-lost source (<c>WriteAlarmQuality(Bad)</c>) delivers a condition event
|
||||
/// whose <c>Quality</c> is non-Good, and recovery (<c>WriteAlarmQuality(Good)</c>) delivers Good again — so a
|
||||
/// client can distinguish "genuinely inactive" from "we have lost contact". Before the fix the field was the
|
||||
/// accidentally-Good default (<c>default(StatusCode) == Good</c>) on every event regardless of the source.</summary>
|
||||
[Fact]
|
||||
public async Task Condition_event_Quality_tracks_source_connectivity_on_the_wire()
|
||||
{
|
||||
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-quality-{Guid.NewGuid():N}");
|
||||
var port = AllocateFreePort();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
try
|
||||
{
|
||||
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", QualityServerUri, ct);
|
||||
await using var _ = host;
|
||||
|
||||
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||
WireCondition(sink);
|
||||
|
||||
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||
|
||||
var collector = new EventCollector(QualityMessageIndex);
|
||||
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
|
||||
subscription.FastEventCallback = collector.OnEvents;
|
||||
session.AddSubscription(subscription);
|
||||
await subscription.CreateAsync(ct);
|
||||
|
||||
AddEventItem(subscription, ObjectIds.Server, BuildQualityEventFilter());
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
// Raise the alarm while the source is healthy → Quality Good.
|
||||
sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
await WaitUntilAsync(() => collector.Fields.Count >= 1, TimeSpan.FromSeconds(5));
|
||||
StatusCode.IsGood((StatusCode)collector.Fields[^1][QualityIndex].Value)
|
||||
.ShouldBeTrue("a healthy source's condition reports Good quality");
|
||||
|
||||
// Device goes comms-lost (connectivity path) → the next condition event carries a Bad Quality, while
|
||||
// the alarm stays active (annotation only — not asserted here, covered by the node-level test).
|
||||
sink.WriteAlarmQuality(RawAlarmPath, OpcUaQuality.Bad, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
await WaitUntilAsync(() => collector.Fields.Count >= 2, TimeSpan.FromSeconds(5));
|
||||
StatusCode.IsBad((StatusCode)collector.Fields[^1][QualityIndex].Value)
|
||||
.ShouldBeTrue("a comms-lost source's condition must report non-Good quality on the wire");
|
||||
|
||||
// Device recovers → Quality returns to Good.
|
||||
sink.WriteAlarmQuality(RawAlarmPath, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
await WaitUntilAsync(() => collector.Fields.Count >= 3, TimeSpan.FromSeconds(5));
|
||||
StatusCode.IsGood((StatusCode)collector.Fields[^1][QualityIndex].Value)
|
||||
.ShouldBeTrue("a recovered source's condition reports Good quality again");
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
SafeDelete(pkiRoot + "-srv");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Materialise the raw device folder + the native condition at its RawPath, plus one referencing
|
||||
/// equipment folder wired as a notifier (the production topology).</summary>
|
||||
private static void WireCondition(SdkAddressSpaceSink sink)
|
||||
@@ -225,6 +288,17 @@ public sealed class NativeAlarmEventIdentityFieldDeliveryTests
|
||||
return filter;
|
||||
}
|
||||
|
||||
/// <summary>#477's clause: [Quality, Message]. Quality is declared on <c>ConditionType</c>, so it must be
|
||||
/// selected against that type. Message rides along as the collector's filter key (a quality-only change still
|
||||
/// snapshots the unchanged Message).</summary>
|
||||
private static EventFilter BuildQualityEventFilter()
|
||||
{
|
||||
var filter = new EventFilter();
|
||||
filter.AddSelectClause(ObjectTypes.ConditionType, BrowseNames.Quality);
|
||||
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
|
||||
return filter;
|
||||
}
|
||||
|
||||
/// <summary>Captures the delivered event field lists, filtered to our unique alarm Message so unrelated
|
||||
/// server events / refresh brackets never count.</summary>
|
||||
/// <param name="messageIndex">Position of the Message field in the select clause this collector is paired
|
||||
|
||||
Reference in New Issue
Block a user