Merge b640-619alm: OpcUaClient native alarms route to RawPath conditions + ConditionRefresh; docker-dev opc-plc seed (#619)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 17:26:47 -04:00
5 changed files with 870 additions and 19 deletions
@@ -255,6 +255,68 @@ public sealed class OpcUaClientDriverWireTests
"the writable variable and the read-only one must classify differently");
}
/// <summary>
/// #619 — ConditionRefresh over the wire: a condition already active+unacked BEFORE the driver subscribes is
/// reported (Raise, keyed by the RawPath) right after subscribe, not only on its next transition. A second
/// subscribe of the same set refreshes again and must emit nothing (no re-raise); a positive control then
/// proves the feed is still live by acknowledging upstream and seeing exactly the Acknowledge arrive.
/// </summary>
[Fact]
public async Task A_condition_already_active_before_subscribe_is_reported_by_ConditionRefresh()
{
const string AlarmRef = "Sim/dev1/TempHi";
var ct = TestContext.Current.CancellationToken;
await using var rig = await StartRigAsync("drv-alarm-refresh", ct);
rig.MaterialiseNative();
rig.Project(native: true, new AlarmConditionSnapshot(Active: true, Acknowledged: false, Confirmed: true,
Enabled: true, AlarmShelvingKind.Unshelved, 800, "already hot"));
await using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions
{
EndpointUrl = rig.EndpointUrl,
RawTags = [Tag(AlarmRef, ServerEventRig.RawNsu(ServerEventRig.RawAlarmPath))],
SecurityPolicy = OpcUaSecurityPolicy.None,
SecurityMode = OpcUaSecurityMode.None,
AuthType = OpcUaAuthType.Username,
Username = "apikey",
Password = ServerEventRig.KeyPassword,
AutoAcceptCertificates = true,
TargetNamespaceKind = OpcUaTargetNamespaceKind.SystemPlatform,
Timeout = TimeSpan.FromSeconds(10),
SessionTimeout = TimeSpan.FromSeconds(30),
KeepAliveInterval = TimeSpan.FromSeconds(1),
}, "wire-alarm-refresh");
await drv.InitializeAsync("{}", ct);
var seen = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) => { lock (seen) seen.Add(e); };
AlarmEventArgs[] Seen() { lock (seen) return [.. seen]; }
var first = await drv.SubscribeAlarmsAsync([AlarmRef], ct);
await RecordedEvents.WaitUntilAsync(() => Seen().Any(e => e.Kind == AlarmTransitionKind.Raise));
var raise = Seen().Single(e => e.Kind == AlarmTransitionKind.Raise);
raise.ConditionId.ShouldBe(AlarmRef);
raise.Message.ShouldBe("already hot");
raise.OpcUaSeverity.ShouldBe(800);
Seen().ShouldAllBe(e => e.ConditionId == AlarmRef, "refresh markers must never surface as alarms");
// Same set again, released first exactly as DriverInstanceActor does → a fresh subscription and another
// ConditionRefresh; the identical state must not re-raise.
var before = Seen().Length;
await drv.UnsubscribeAlarmsAsync(first, ct);
await drv.SubscribeAlarmsAsync([AlarmRef], ct);
await Task.Delay(1500, ct); // let the second refresh's events land before the control transition
// Positive control: an upstream acknowledge arrives as exactly one Acknowledge, after which nothing else.
rig.Project(native: true, new AlarmConditionSnapshot(Active: true, Acknowledged: true, Confirmed: true,
Enabled: true, AlarmShelvingKind.Unshelved, 800, "already hot"));
await RecordedEvents.WaitUntilAsync(() => Seen().Any(e => e.Kind == AlarmTransitionKind.Acknowledge));
await Task.Delay(500, ct);
Seen().Skip(before).Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Acknowledge],
"the second refresh re-delivered an unchanged state and must have emitted nothing");
}
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];