fix(opcuaclient): alarm index survives namespace reorder; match condition node first; ack with real EventId (#619 review)
The alarm index was keyed on ns=N strings resolved once at subscribe time, while an in-place SDK reconnect transfers the subscription without re-running it - a remote namespace reorder silently orphaned every alarm tag. Keys are now nsu= on both sides. Events match the authored nodeId against the event's ConditionId first, then its SourceNode, so several conditions on one source can be authored 1:1. Acknowledge passes the EventId of the event that named the remembered condition (paired, so node and EventId can never come from different conditions) instead of an empty one. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -113,7 +113,7 @@ public sealed class OpcUaClientAlarmTests
|
||||
|
||||
var e = OpcUaClientDriver.ProjectEvent(
|
||||
EventFields("ns=3;s=Alarms.Trip1", "ns=3;s=Alarms.Trip1.Cond"), index, new FakeAlarmHandle(),
|
||||
(raw, cond) => { rememberedRaw = raw; rememberedCond = cond; });
|
||||
n => n.ToString(), (raw, target) => { rememberedRaw = raw; rememberedCond = target.ConditionRef; });
|
||||
|
||||
e.ShouldNotBeNull();
|
||||
e.ConditionId.ShouldBe("Plant/opc/plc/Trip1");
|
||||
@@ -128,10 +128,74 @@ public sealed class OpcUaClientAlarmTests
|
||||
{
|
||||
var index = new Dictionary<string, string> { ["ns=3;s=Alarms.Trip1"] = "Plant/opc/plc/Trip1" };
|
||||
OpcUaClientDriver.ProjectEvent(
|
||||
EventFields("ns=3;s=Other", "ns=3;s=Other.Cond"), index, new FakeAlarmHandle(), (_, _) => { })
|
||||
EventFields("ns=3;s=Other", "ns=3;s=Other.Cond"), index, new FakeAlarmHandle(), n => n.ToString(), (_, _) => { })
|
||||
.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A remote namespace-table reorder across an in-place SDK reconnect must not orphan the alarm index:
|
||||
/// it is built once at subscribe time, while events keep arriving on the (transferred) subscription with
|
||||
/// the NEW namespace indexes. Keys are therefore server-stable (<c>nsu=</c>) on both sides.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ProjectEvent_matches_across_a_namespace_reorder()
|
||||
{
|
||||
var before = new NamespaceTable(); before.Append("urn:other"); before.Append("urn:plc"); // urn:plc = 2
|
||||
var after = new NamespaceTable(); after.Append("urn:new"); after.Append("urn:other"); after.Append("urn:plc"); // urn:plc = 3
|
||||
var mapBefore = NamespaceMap.FromTable(before);
|
||||
var mapAfter = NamespaceMap.FromTable(after);
|
||||
|
||||
var index = OpcUaClientDriver.BuildAlarmSourceIndex(
|
||||
["Plant/opc/plc/Trip1"], _ => OpcUaClientDriver.AlarmIndexKey(mapBefore, new NodeId("Alarms.Trip1", 2)));
|
||||
|
||||
var e = OpcUaClientDriver.ProjectEvent(
|
||||
EventFields("ns=3;s=Alarms.Trip1", "ns=3;s=Alarms.Trip1.Cond"), index, new FakeAlarmHandle(),
|
||||
n => OpcUaClientDriver.AlarmIndexKey(mapAfter, n), (_, _) => { });
|
||||
|
||||
e.ShouldNotBeNull();
|
||||
e.ConditionId.ShouldBe("Plant/opc/plc/Trip1");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A source with several conditions (Hi + HiHi on one variable) can only be told apart by the CONDITION
|
||||
/// node, so a tag authored on the condition node matches on the event's ConditionId — 1:1, no collapse.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ProjectEvent_matches_a_tag_authored_on_the_condition_node()
|
||||
{
|
||||
var index = new Dictionary<string, string>
|
||||
{
|
||||
["ns=3;s=Tank.HiHi"] = "Plant/opc/plc/HiHi",
|
||||
["ns=3;s=Tank.Hi"] = "Plant/opc/plc/Hi",
|
||||
};
|
||||
|
||||
var e = OpcUaClientDriver.ProjectEvent(
|
||||
EventFields("ns=3;s=Tank", "ns=3;s=Tank.HiHi"), index, new FakeAlarmHandle(), n => n.ToString(), (_, _) => { });
|
||||
|
||||
e.ShouldNotBeNull();
|
||||
e.ConditionId.ShouldBe("Plant/opc/plc/HiHi");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The ack target pairs the condition node WITH the EventId of the event that named it, so Acknowledge
|
||||
/// carries the real EventId (Part 9 §5.7.3) instead of an empty one a strict server rejects
|
||||
/// <c>BadEventIdUnknown</c>, and the two can never come from different conditions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ProjectEvent_remembers_the_condition_with_its_EventId()
|
||||
{
|
||||
var index = new Dictionary<string, string> { ["ns=3;s=Alarms.Trip1"] = "Plant/opc/plc/Trip1" };
|
||||
OpcUaClientDriver.AlarmAckTarget? target = null;
|
||||
|
||||
OpcUaClientDriver.ProjectEvent(
|
||||
EventFields("ns=3;s=Alarms.Trip1", "ns=3;s=Alarms.Trip1.Cond"), index, new FakeAlarmHandle(),
|
||||
n => n.ToString(), (_, t) => target = t);
|
||||
|
||||
target.ShouldNotBeNull();
|
||||
target.Value.ConditionRef.ShouldBe("ns=3;s=Alarms.Trip1.Cond");
|
||||
target.Value.EventId.ShouldBe(new byte[] { 1 });
|
||||
}
|
||||
|
||||
private sealed class FakeAlarmHandle : IAlarmSubscriptionHandle
|
||||
{
|
||||
/// <summary>Gets the diagnostic identifier for this alarm handle.</summary>
|
||||
|
||||
Reference in New Issue
Block a user