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:
@@ -209,6 +209,8 @@ public class DeferredAddressSpaceSinkTests
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
=> WriteValueCalled = true;
|
||||
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
@@ -226,6 +228,7 @@ public class DeferredAddressSpaceSinkTests
|
||||
public bool FolderRenameCalled { get; private set; }
|
||||
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
|
||||
+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
|
||||
|
||||
+2
@@ -163,6 +163,8 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
|
||||
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
{
|
||||
if (ThrowOnAlarmWrite) throw new InvalidOperationException("simulated WriteAlarmCondition fault");
|
||||
|
||||
@@ -312,6 +312,7 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
|
||||
/// <param name="alarmNodeId">The node ID of the alarm condition.</param>
|
||||
/// <param name="state">The full condition state snapshot.</param>
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
/// <summary>Materialises an alarm condition (stub implementation for testing).</summary>
|
||||
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
|
||||
|
||||
@@ -301,6 +301,7 @@ public sealed class AddressSpaceApplierRawUnsTests
|
||||
=> References.Add((sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType));
|
||||
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void RebuildAddressSpace() { }
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
|
||||
|
||||
@@ -2268,6 +2268,7 @@ public sealed class AddressSpaceApplierTests
|
||||
/// <param name="alarmNodeId">The alarm node ID.</param>
|
||||
/// <param name="state">The full condition state snapshot.</param>
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
=> AlarmQueue.Enqueue((alarmNodeId, state));
|
||||
/// <summary>Records an alarm-condition materialise call.</summary>
|
||||
@@ -2322,6 +2323,7 @@ public sealed class AddressSpaceApplierTests
|
||||
/// <summary>Records a value write (no-op in this sink).</summary>
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
/// <summary>No-op alarm condition write call.</summary>
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
/// <summary>No-op alarm-condition materialise call.</summary>
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||
@@ -2355,6 +2357,7 @@ public sealed class AddressSpaceApplierTests
|
||||
/// <param name="state">The full condition state snapshot.</param>
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
/// <exception cref="InvalidOperationException">Thrown when configured to throw on alarm write.</exception>
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
{
|
||||
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
|
||||
|
||||
@@ -231,6 +231,7 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
=> CallQueue.Enqueue($"WV:{nodeId}");
|
||||
/// <inheritdoc />
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
=> CallQueue.Enqueue($"WA:{alarmNodeId}");
|
||||
/// <inheritdoc />
|
||||
@@ -289,6 +290,7 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
/// <inheritdoc />
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
/// <inheritdoc />
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
/// <inheritdoc />
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||
|
||||
+114
@@ -196,6 +196,120 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable
|
||||
condition.ConditionClassName!.Value.Text.ShouldBe("BaseConditionClass");
|
||||
}
|
||||
|
||||
/// <summary>#477 — a freshly materialised NATIVE condition reports BadWaitingForInitialData quality (not the
|
||||
/// accidentally-Good default), matching the value-variable "no driver data yet" convention. Its quality only
|
||||
/// becomes Good once its driver's connectivity confirms it (Layer 2).</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task Native_condition_materialises_with_waiting_for_initial_data_quality()
|
||||
{
|
||||
await using var host = await BootAsync();
|
||||
var nm = host.Nm;
|
||||
|
||||
nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||
nm.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "HR200", "OffNormalAlarm", 700,
|
||||
realm: AddressSpaceRealm.Raw, isNative: true);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
|
||||
condition.ShouldNotBeNull();
|
||||
condition.Quality.ShouldNotBeNull();
|
||||
condition.Quality.Value.ShouldBe((StatusCode)StatusCodes.BadWaitingForInitialData);
|
||||
StatusCode.IsGood(condition.Quality.Value).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>#477 — a SCRIPTED condition is script-computed and always "live" in v1, so it materialises Good.
|
||||
/// (Scripted worst-of-input quality is deferred to Layer 3.)</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task Scripted_condition_materialises_with_good_quality()
|
||||
{
|
||||
await using var host = await BootAsync();
|
||||
var nm = host.Nm;
|
||||
|
||||
nm.EnsureFolder("eq-q1", parentNodeId: null, displayName: "Station Q1", realm: AddressSpaceRealm.Uns);
|
||||
nm.MaterialiseAlarmCondition("scripted-q", "eq-q1", "Scripted Q", "OffNormalAlarm", 700,
|
||||
realm: AddressSpaceRealm.Uns, isNative: false);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition("scripted-q", AddressSpaceRealm.Uns);
|
||||
condition.ShouldNotBeNull();
|
||||
condition.Quality.ShouldNotBeNull();
|
||||
StatusCode.IsGood(condition.Quality.Value).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>#477 — WriteAlarmCondition projects the snapshot's Quality onto the live condition node, so a
|
||||
/// Bad-quality snapshot (comms-lost source) makes the condition report non-Good.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task WriteAlarmCondition_projects_snapshot_quality_onto_the_condition()
|
||||
{
|
||||
await using var host = await BootAsync();
|
||||
var nm = host.Nm;
|
||||
|
||||
nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||
nm.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "HR200", "OffNormalAlarm", 700,
|
||||
realm: AddressSpaceRealm.Raw, isNative: true);
|
||||
|
||||
// A comms-lost snapshot: the alarm state is unchanged but Quality is Bad.
|
||||
var badSnapshot = new AlarmConditionSnapshot(
|
||||
Active: false, Acknowledged: true, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: "HR200",
|
||||
Quality: OpcUaQuality.Bad);
|
||||
nm.WriteAlarmCondition(RawAlarmPath, badSnapshot, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
|
||||
condition.ShouldNotBeNull();
|
||||
StatusCode.IsBad(condition.Quality.Value).ShouldBeTrue();
|
||||
|
||||
// Recover: a Good snapshot restores Good quality.
|
||||
var goodSnapshot = badSnapshot with { Quality = OpcUaQuality.Good };
|
||||
nm.WriteAlarmCondition(RawAlarmPath, goodSnapshot, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
StatusCode.IsGood(condition.Quality.Value).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>#477 Layer 2 — the dedicated connectivity-quality path sets ONLY the condition's Quality
|
||||
/// (comms lost → Bad, restored → Good) and never clobbers the node's severity / message / alarm state:
|
||||
/// it is a pure annotation applied out-of-band from any alarm transition.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task WriteAlarmQuality_sets_only_quality_without_touching_state_or_severity()
|
||||
{
|
||||
await using var host = await BootAsync();
|
||||
var nm = host.Nm;
|
||||
|
||||
nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||
nm.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "HR200", "OffNormalAlarm", 700,
|
||||
realm: AddressSpaceRealm.Raw, isNative: true);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
|
||||
condition.ShouldNotBeNull();
|
||||
var severityBefore = condition.Severity!.Value;
|
||||
var messageBefore = condition.Message!.Value?.Text;
|
||||
|
||||
// Device comes online → Good.
|
||||
nm.WriteAlarmQuality(RawAlarmPath, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
StatusCode.IsGood(condition.Quality!.Value).ShouldBeTrue();
|
||||
|
||||
// Device goes comms-lost → Bad, but severity / message / active-ack state are untouched.
|
||||
nm.WriteAlarmQuality(RawAlarmPath, OpcUaQuality.Bad, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
StatusCode.IsBad(condition.Quality.Value).ShouldBeTrue();
|
||||
condition.Severity.Value.ShouldBe(severityBefore);
|
||||
condition.Message.Value?.Text.ShouldBe(messageBefore);
|
||||
condition.ActiveState!.Id!.Value.ShouldBeFalse(); // unchanged: annotation only
|
||||
}
|
||||
|
||||
/// <summary>#477 Layer 2 — WriteAlarmQuality on an unknown / unmaterialised node is a safe no-op (never
|
||||
/// throws), mirroring the other write paths: a mid-rebuild race must not fault a connectivity update.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task WriteAlarmQuality_is_a_noop_for_an_unknown_condition()
|
||||
{
|
||||
await using var host = await BootAsync();
|
||||
var nm = host.Nm;
|
||||
|
||||
Should.NotThrow(() =>
|
||||
nm.WriteAlarmQuality("no/such/node", OpcUaQuality.Bad, DateTime.UtcNow, AddressSpaceRealm.Raw));
|
||||
}
|
||||
|
||||
/// <summary>A booted server + its node manager, disposed via <c>await using</c> so an assertion failure
|
||||
/// cannot leak a live server (and its bound port) into the rest of the test run.</summary>
|
||||
private sealed class BootedServer(OpcUaApplicationHost host, OtOpcUaNodeManager nm) : IAsyncDisposable
|
||||
|
||||
@@ -523,7 +523,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
{
|
||||
var baseState = new OtOpcUaNodeManager.AlarmConditionDelta(
|
||||
Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, MappedSeverity: 100, Message: "m");
|
||||
Shelving: AlarmShelvingKind.Unshelved, MappedSeverity: 100, Message: "m",
|
||||
Quality: StatusCodes.Good);
|
||||
|
||||
// Equal ⇒ suppress (this is the inbound double-emit case in pure form).
|
||||
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState).ShouldBeFalse();
|
||||
@@ -537,6 +538,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Shelving = AlarmShelvingKind.Timed }).ShouldBeTrue();
|
||||
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { MappedSeverity = 900 }).ShouldBeTrue();
|
||||
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Message = "other" }).ShouldBeTrue();
|
||||
// #477 — a quality-only change (e.g. source going comms-lost, no state change) is a genuine delta.
|
||||
OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Quality = StatusCodes.Bad }).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>T20 — null-vs-empty Message normalization. Both snapshot.Message = null and a live node
|
||||
@@ -552,7 +555,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
// Both null and "" collapse to string.Empty in AlarmConditionDelta — they are the same delta value.
|
||||
var withNull = new OtOpcUaNodeManager.AlarmConditionDelta(
|
||||
Active: false, Acknowledged: true, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, MappedSeverity: 100, Message: string.Empty);
|
||||
Shelving: AlarmShelvingKind.Unshelved, MappedSeverity: 100, Message: string.Empty,
|
||||
Quality: StatusCodes.Good);
|
||||
|
||||
var withEmpty = withNull with { Message = string.Empty };
|
||||
|
||||
|
||||
+1
@@ -328,6 +328,7 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
|
||||
=> _values.Enqueue((nodeId, value, quality, sourceTimestampUtc));
|
||||
|
||||
/// <summary>No-op: alarm writes are not exercised by this suite.</summary>
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
|
||||
/// <summary>No-op: alarm materialise is not exercised by this suite.</summary>
|
||||
|
||||
+40
@@ -271,6 +271,46 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
evt.User.ShouldBe("device");
|
||||
}
|
||||
|
||||
/// <summary>#477 Layer 2 — a driver <see cref="DriverInstanceActor.ConnectivityChanged"/> annotates the
|
||||
/// Quality of the driver's native conditions via <see cref="OpcUaPublishActor.AlarmQualityUpdate"/>: comms
|
||||
/// lost → Bad, restored → Good, at the raw condition NodeId + realm. No alarm transition is involved — this
|
||||
/// is the ONLY signal for a comms-lost native source.</summary>
|
||||
[Fact]
|
||||
public void Driver_disconnect_and_reconnect_annotate_native_condition_quality()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||
|
||||
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
|
||||
|
||||
// Comms lost → the raw condition is annotated Bad.
|
||||
actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: false));
|
||||
var bad = publish.ExpectMsg<OpcUaPublishActor.AlarmQualityUpdate>(TimeSpan.FromSeconds(5));
|
||||
bad.AlarmNodeId.ShouldBe(AlarmRawPath);
|
||||
bad.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
bad.Quality.ShouldBe(OpcUaQuality.Bad);
|
||||
|
||||
// Comms restored → annotated Good.
|
||||
actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: true));
|
||||
var good = publish.ExpectMsg<OpcUaPublishActor.AlarmQualityUpdate>(TimeSpan.FromSeconds(5));
|
||||
good.AlarmNodeId.ShouldBe(AlarmRawPath);
|
||||
good.Quality.ShouldBe(OpcUaQuality.Good);
|
||||
}
|
||||
|
||||
/// <summary>#477 Layer 2 — connectivity for a DIFFERENT driver instance annotates none of this driver's
|
||||
/// conditions (the fan-out is scoped by DriverInstanceId).</summary>
|
||||
[Fact]
|
||||
public void Connectivity_for_another_driver_annotates_nothing()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||
|
||||
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-OTHER", Connected: false));
|
||||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Subscribe <paramref name="probe"/> to the <c>alerts</c> DPS topic and wait for the ack.</summary>
|
||||
private void SubscribeToAlerts(TestProbe probe)
|
||||
{
|
||||
|
||||
+39
@@ -30,6 +30,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new DiscoverableStubDriver();
|
||||
var parent = CreateTestProbe();
|
||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||
// Tiny interval so the bounded retry runs in well under a second (no real-time waits).
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
||||
@@ -68,6 +71,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new SubscribableStubDriver(); // IDriver + ISubscribable, NOT ITagDiscovery
|
||||
var parent = CreateTestProbe();
|
||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
||||
|
||||
@@ -92,6 +98,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new DiscoverableStubDriver();
|
||||
var parent = CreateTestProbe();
|
||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||
// Tiny reconnect + rediscover intervals so the whole reconnect-then-rediscover cycle runs fast.
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||
driver,
|
||||
@@ -133,6 +142,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new YieldingDiscoverableStubDriver();
|
||||
var parent = CreateTestProbe();
|
||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
||||
|
||||
@@ -167,6 +179,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
||||
driverType: "Stub");
|
||||
var driver = new YieldingDiscoverableStubDriver();
|
||||
var parent = CreateTestProbe();
|
||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20), invoker: invoker));
|
||||
|
||||
@@ -187,6 +202,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new GrowingDiscoverableStubDriver();
|
||||
var parent = CreateTestProbe();
|
||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20), rediscoverMaxAttempts: 3));
|
||||
|
||||
@@ -215,6 +233,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new DiscoverableStubDriver(DiscoveryRediscoverPolicy.Never);
|
||||
var parent = CreateTestProbe();
|
||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
||||
|
||||
@@ -239,6 +260,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
|
||||
var parent = CreateTestProbe();
|
||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
||||
|
||||
@@ -269,6 +293,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
|
||||
var parent = CreateTestProbe();
|
||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||
// Small reconnect + rediscover intervals so the cycle runs fast; cap 3 so a (wrong) full loop is
|
||||
// visibly more than the one pass Once must run per (re)connect.
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||
@@ -314,6 +341,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
||||
// Props must accept the new optional parameter — no throw and actor starts normally.
|
||||
var driver = new DiscoverableStubDriver();
|
||||
var parent = CreateTestProbe();
|
||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||
driver,
|
||||
rediscoverInterval: TimeSpan.FromMilliseconds(20),
|
||||
@@ -340,6 +370,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
||||
// (no second settling pass to drain, and no stale-tick double pass alongside the fresh one).
|
||||
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
|
||||
var parent = CreateTestProbe();
|
||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
||||
|
||||
@@ -373,6 +406,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new DiscoverableStubDriver(DiscoveryRediscoverPolicy.Never);
|
||||
var parent = CreateTestProbe();
|
||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
||||
|
||||
@@ -400,6 +436,9 @@ public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
||||
// a long reconnect interval so the actor parks in Reconnecting deterministically within the test window.
|
||||
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
|
||||
var parent = CreateTestProbe();
|
||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||
driver,
|
||||
reconnectInterval: TimeSpan.FromSeconds(30),
|
||||
|
||||
+42
@@ -30,6 +30,9 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new AlarmSourceStubDriver();
|
||||
var parent = CreateTestProbe();
|
||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
@@ -77,6 +80,9 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new AlarmSourceStubDriver();
|
||||
var parent = CreateTestProbe();
|
||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
@@ -110,6 +116,9 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new SubscribableOnlyStubDriver();
|
||||
var parent = CreateTestProbe();
|
||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
@@ -170,6 +179,9 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
|
||||
// Long reconnect interval (default 10 s) so the retry doesn't fire during the assertion window.
|
||||
var driver = new AlarmSourceStubDriver();
|
||||
var parent = CreateTestProbe();
|
||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
|
||||
|
||||
// Drive to Connected; confirm the alarm handler is attached.
|
||||
@@ -217,6 +229,9 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new AlarmSourceStubDriver();
|
||||
var parent = CreateTestProbe();
|
||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50)));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
@@ -245,6 +260,33 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
|
||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
}
|
||||
|
||||
/// <summary>#477 Layer 2 — the actor announces connectivity to its parent (the host): Connected on
|
||||
/// reaching the Connected state, then Connected=false on a <see cref="DriverInstanceActor.DisconnectObserved"/>,
|
||||
/// then Connected=true again on the reconnect. This is the signal the host uses to annotate the driver's
|
||||
/// native alarm conditions' Quality (comms lost → Bad, restored → Good).</summary>
|
||||
[Fact]
|
||||
public void ConnectivityChanged_is_announced_to_parent_on_connect_disconnect_and_reconnect()
|
||||
{
|
||||
var driver = new AlarmSourceStubDriver();
|
||||
var parent = CreateTestProbe(); // deliberately does NOT ignore ConnectivityChanged — it's the subject.
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50)));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
|
||||
// Initial connect → Connected=true.
|
||||
parent.FishForMessage<DriverInstanceActor.ConnectivityChanged>(m => true, TimeSpan.FromSeconds(3))
|
||||
.Connected.ShouldBeTrue();
|
||||
|
||||
// Disconnect → Connected=false (this is what drives the conditions Bad).
|
||||
actor.Tell(new DriverInstanceActor.DisconnectObserved("backend blip"));
|
||||
parent.FishForMessage<DriverInstanceActor.ConnectivityChanged>(m => true, TimeSpan.FromSeconds(3))
|
||||
.Connected.ShouldBeFalse();
|
||||
|
||||
// Reconnect → Connected=true again (drives them back Good).
|
||||
parent.FishForMessage<DriverInstanceActor.ConnectivityChanged>(m => true, TimeSpan.FromSeconds(3))
|
||||
.Connected.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// --- stub drivers ----------------------------------------------------------------------------
|
||||
|
||||
private class StubDriver : IDriver
|
||||
|
||||
@@ -109,6 +109,9 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new SubscribableStubDriver();
|
||||
var parent = CreateTestProbe();
|
||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
@@ -134,6 +137,9 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new SubscribableStubDriver();
|
||||
var parent = CreateTestProbe();
|
||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
@@ -162,6 +168,9 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new SubscribableStubDriver();
|
||||
var parent = CreateTestProbe();
|
||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50)));
|
||||
|
||||
// Desired set arrives BEFORE connect — retained, not yet applied.
|
||||
@@ -197,6 +206,9 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
||||
// is used — the exact condition that throws NotSupportedException on the subsequent Sender read.
|
||||
var driver = new SubscribableStubDriver { UnsubscribeYields = true };
|
||||
var parent = CreateTestProbe();
|
||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
@@ -242,6 +254,9 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
||||
{
|
||||
var driver = new SubscribableStubDriver();
|
||||
var parent = CreateTestProbe();
|
||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromSeconds(30)));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
@@ -271,6 +286,9 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
||||
InitBehavior = cfg => cfg == good ? Task.CompletedTask : throw new InvalidOperationException("bad-cfg"),
|
||||
};
|
||||
var parent = CreateTestProbe();
|
||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50)));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)));
|
||||
@@ -304,6 +322,9 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
||||
InitBehavior = cfg => cfg == v1 ? gate1.Task : gate2.Task,
|
||||
};
|
||||
var parent = CreateTestProbe();
|
||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromSeconds(30)));
|
||||
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)));
|
||||
|
||||
|
||||
@@ -153,6 +153,19 @@ public class NativeAlarmProjectorTests
|
||||
snap.Acknowledged.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// #477 — native transitions carry Good quality; comms-loss quality is applied out-of-band via the
|
||||
// dedicated WriteAlarmQuality path (connectivity → DriverHostActor), NOT through the projector, so the
|
||||
// projector stays quality-agnostic and its transition snapshots default to Good.
|
||||
[Fact]
|
||||
public void Transition_projection_defaults_to_good_quality()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
|
||||
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Raise));
|
||||
|
||||
snap.Quality.ShouldBe(OpcUaQuality.Good);
|
||||
}
|
||||
|
||||
private static AlarmEventArgs Evt(
|
||||
AlarmTransitionKind kind,
|
||||
AlarmSeverity sev = AlarmSeverity.High,
|
||||
|
||||
+1
@@ -199,6 +199,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
|
||||
/// <param name="alarmNodeId">The alarm node identifier.</param>
|
||||
/// <param name="state">The full condition state snapshot.</param>
|
||||
/// <param name="occurredUtc">The time the alarm occurred in UTC.</param>
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Writes++;
|
||||
/// <summary>Materialises an alarm condition (stub implementation).</summary>
|
||||
/// <param name="alarmNodeId">The alarm node identifier.</param>
|
||||
|
||||
+2
@@ -119,6 +119,7 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
|
||||
private sealed class ThrowOnRebuildSink : IOpcUaAddressSpaceSink
|
||||
{
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
@@ -133,6 +134,7 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
|
||||
private sealed class NoopSink : IOpcUaAddressSpaceSink
|
||||
{
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
|
||||
@@ -443,6 +443,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
/// <param name="alarmNodeId">The alarm node ID.</param>
|
||||
/// <param name="state">The full condition state snapshot.</param>
|
||||
/// <param name="ts">The timestamp of the state change.</param>
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
=> Calls.Enqueue($"WA:{alarmNodeId}");
|
||||
/// <summary>Records a materialise-alarm-condition call.</summary>
|
||||
|
||||
@@ -76,6 +76,26 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
||||
}, duration: TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>#477 — AlarmQualityUpdate routes to sink.WriteAlarmQuality with the quality + realm.</summary>
|
||||
[Fact]
|
||||
public void AlarmQualityUpdate_routes_to_sink_WriteAlarmQuality()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
|
||||
|
||||
actor.Tell(new OpcUaPublishActor.AlarmQualityUpdate(
|
||||
"Plant/Modbus/dev1/temp_hi", OpcUaQuality.Bad, DateTime.UtcNow, Realm: AddressSpaceRealm.Raw));
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
sink.AlarmQualityQueue.Count.ShouldBe(1);
|
||||
sink.AlarmQualityQueue.TryPeek(out var q).ShouldBeTrue();
|
||||
q.AlarmNodeId.ShouldBe("Plant/Modbus/dev1/temp_hi");
|
||||
q.Quality.ShouldBe(OpcUaQuality.Bad);
|
||||
q.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
}, duration: TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Builds a test <see cref="AlarmConditionSnapshot"/> with sensible defaults so each test
|
||||
/// only specifies the fields it cares about.</summary>
|
||||
private static AlarmConditionSnapshot Snapshot(
|
||||
@@ -577,6 +597,8 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
||||
public ConcurrentQueue<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> ValueQueue { get; } = new();
|
||||
/// <summary>Gets the queue of recorded alarm condition updates.</summary>
|
||||
public ConcurrentQueue<(string AlarmNodeId, AlarmConditionSnapshot State, DateTime Ts)> AlarmQueue { get; } = new();
|
||||
/// <summary>Gets the queue of recorded alarm-quality annotations (#477).</summary>
|
||||
public ConcurrentQueue<(string AlarmNodeId, OpcUaQuality Quality, DateTime Ts, AddressSpaceRealm Realm)> AlarmQualityQueue { get; } = new();
|
||||
/// <summary>Count of rebuild calls.</summary>
|
||||
public int RebuildCalls;
|
||||
/// <summary>Gets the queue of recorded EnsureFolder calls.</summary>
|
||||
@@ -616,6 +638,10 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
|
||||
AlarmQueue.Enqueue((alarmNodeId, state, ts));
|
||||
|
||||
/// <summary>Records an alarm-quality annotation (#477).</summary>
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
|
||||
AlarmQualityQueue.Enqueue((alarmNodeId, quality, ts, realm));
|
||||
|
||||
/// <summary>Materialises an alarm condition (no-op in test).</summary>
|
||||
/// <param name="alarmNodeId">The alarm node ID.</param>
|
||||
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
|
||||
|
||||
Reference in New Issue
Block a user