diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index cf4d7372..aafeecbf 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -110,6 +110,17 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers private readonly Dictionary _driverRefByNodeId = new(StringComparer.Ordinal); + /// (DriverInstanceId, FullName = alarm SourceNodeId) → folder-scoped condition NodeId(s). + /// Built from EquipmentTags whose plan carries Alarm, alongside the value maps; resolves a native + /// alarm transition to the materialised Part 9 condition node(s). Alarm tags are conditions, not + /// value variables, so they are kept OUT of the value maps + value-subscription set. + private readonly Dictionary<(string DriverInstanceId, string FullName), HashSet> _alarmNodeIdByDriverRef = new(); + + /// Derives a full Part 9 condition snapshot from each native alarm transition delta, + /// tracking per-condition-NodeId prior state. 'd on every apply alongside the + /// value maps so stale condition state never leaks across redeploys. + private readonly NativeAlarmProjector _nativeAlarmProjector = new(); + /// /// Cached local from the latest /// snapshot (null = unknown until the first snapshot arrives, or no local node match). The inbound @@ -406,6 +417,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers Receive(HandleDispatchFromSteady); Receive(HandleGetDiagnostics); Receive(ForwardToMux); + Receive(ForwardNativeAlarm); Receive(HandleRestartDriver); Receive(HandleReconnectDriver); Receive(HandleRouteNodeWrite); @@ -429,6 +441,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers }); Receive(HandleGetDiagnostics); Receive(ForwardToMux); + Receive(ForwardNativeAlarm); Receive(HandleRestartDriver); Receive(HandleReconnectDriver); Receive(HandleRouteNodeWrite); @@ -466,6 +479,36 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers } } + /// + /// Routes a native alarm transition (published by a driver child as + /// ) to its materialised Part 9 condition + /// node(s). The alarm path analogue of : the driver fires keyed by the + /// alarm source's SourceNodeId (the equipment tag's wire-ref FullName), which the + /// map — built each apply from the alarm-bearing EquipmentTags — + /// resolves to the folder-scoped condition NodeId(s) the materialiser placed the condition(s) at. + /// For each node the projects the transition delta into a full + /// AlarmConditionSnapshot, then this Tells + /// — the SAME message scripted alarms use, so it routes through WriteAlarmCondition. An + /// unknown ref is Debug-logged and dropped (mirrors the value drop). The /alerts fan-out is a + /// separate concern (Task 7) and is NOT emitted here. + /// + private void ForwardNativeAlarm(DriverInstanceActor.AttributeAlarmPublished msg) + { + if (_opcUaPublishActor is null) return; + if (!_alarmNodeIdByDriverRef.TryGetValue((msg.DriverInstanceId, msg.Args.SourceNodeId), out var nodeIds)) + { + _log.Debug("DriverHost {Node}: no alarm condition for ({Driver},{Ref}) — transition dropped", + _localNode, msg.DriverInstanceId, msg.Args.SourceNodeId); + return; + } + foreach (var nodeId in nodeIds) + { + var snapshot = _nativeAlarmProjector.Project(nodeId, msg.Args); + _opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmStateUpdate( + nodeId, snapshot, msg.Args.SourceTimestampUtc)); + } + } + /// /// Routes an inbound operator write (Task 11 Asks this from the OPC UA node-manager side) to the /// owning driver child. Order matters: @@ -731,7 +774,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers return; } + // Value-subscription set: alarm-bearing tags are Part 9 conditions, not value variables, so they + // are excluded — the driver must not value-subscribe an alarm attribute (it is fed via the native + // alarm event stream, routed by ForwardNativeAlarm). var refsByDriver = composition.EquipmentTags + .Where(t => t.Alarm is null) .GroupBy(t => t.DriverInstanceId, StringComparer.Ordinal) .ToDictionary( g => g.Key, @@ -752,10 +799,27 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers // reflected. Each NodeId maps to exactly one driver ref (a variable is backed by a single driver // attribute), so last-writer-wins on the rare duplicate is harmless. _driverRefByNodeId.Clear(); + // Alarm condition routing map: (DriverInstanceId, FullName = alarm SourceNodeId) → folder-scoped + // condition NodeId(s). Built from the SAME EquipmentTags pass (alarm-bearing tags only) so + // ForwardNativeAlarm can land a native transition on the right condition node. Clear-and-rebuild + // every apply; the projector is Clear()'d too so stale per-condition state never leaks across + // redeploys (renames/removals/address-space rebuilds). + _alarmNodeIdByDriverRef.Clear(); + _nativeAlarmProjector.Clear(); foreach (var t in composition.EquipmentTags) { var key = (t.DriverInstanceId, t.FullName); var nodeId = EquipmentNodeIds.Variable(t.EquipmentId, t.FolderPath, t.Name); + if (t.Alarm is not null) + { + // Alarm tags are conditions, not value variables: route them ONLY into the alarm map and + // keep them OUT of the value maps + value-subscription set (so they don't get both a value + // variable AND a condition). + if (!_alarmNodeIdByDriverRef.TryGetValue(key, out var aset)) + _alarmNodeIdByDriverRef[key] = aset = new HashSet(StringComparer.Ordinal); + aset.Add(nodeId); + continue; + } if (!_nodeIdByDriverRef.TryGetValue(key, out var set)) _nodeIdByDriverRef[key] = set = new HashSet(StringComparer.Ordinal); set.Add(nodeId); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs new file mode 100644 index 00000000..d4a475c4 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs @@ -0,0 +1,192 @@ +using System.Text.Json; +using Akka.Actor; +using Microsoft.EntityFrameworkCore; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Engines; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; +using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; +using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +/// +/// Verifies the equipment-tag native-alarm routing wired into +/// (Phase B WS-4c, the LIVE-CONDITION half): a driver child publishes a native alarm transition as +/// keyed by the alarm source's +/// SourceNodeId (the equipment tag's wire-ref FullName), but the materialised condition +/// lives at a FOLDER-SCOPED NodeId ({equipmentId}/{folderPath}/{name}). After an apply, the +/// host's _alarmNodeIdByDriverRef map (built only from alarm-bearing EquipmentTags) resolves +/// (DriverInstanceId, SourceNodeId) to that NodeId, the NativeAlarmProjector projects +/// the transition into a full AlarmConditionSnapshot, and ForwardNativeAlarm Tells the +/// publish actor an — the same message scripted +/// alarms use. +/// +/// +/// Mirrors the value-routing harness in DriverHostActorLiveValueTests: the seeded artifact +/// carries the Namespaces / DriverInstances / Tags arrays, with each alarm +/// tag's TagConfig carrying an alarm object so +/// DeploymentArtifact.ExtractTagAlarm projects a non-null +/// EquipmentTagAlarmInfo. The OPC UA sink + dependency mux are injected as TestProbes. +/// +/// +public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase +{ + private static readonly NodeId TestNode = NodeId.Parse("driver-alarm-test"); + private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); + private static readonly DateTime Ts = new(2026, 6, 14, 10, 0, 0, DateTimeKind.Utc); + + /// A native alarm RAISE published by SourceNodeId (the alarm tag's FullName) lands on the + /// condition's folder-scoped NodeId (here eq-1/temp_hi) as an + /// with State.Active == true. + [Fact] + public void Native_alarm_raise_routes_to_folder_scoped_condition_NodeId_active() + { + var db = NewInMemoryDbFactory(); + // One alarm-bearing equipment tag: eq-1, drv-1, FullName "Temp.HiHi", no folder, Name "temp_hi". + var deploymentId = SeedDeploymentWithAlarmTag(db, RevA, + Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi"); + + var (actor, publish) = SpawnHostAndApply(db, deploymentId); + + actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( + new StubAlarmHandle(), + SourceNodeId: "Temp.HiHi", + ConditionId: "cond-1", + AlarmType: "OffNormalAlarm", + Message: "temperature high", + Severity: AlarmSeverity.High, + SourceTimestampUtc: Ts, + Kind: AlarmTransitionKind.Raise))); + + var update = publish.ExpectMsg(TimeSpan.FromSeconds(5)); + update.AlarmNodeId.ShouldBe("eq-1/temp_hi"); + update.State.Active.ShouldBeTrue(); + update.State.Acknowledged.ShouldBeFalse(); + update.TimestampUtc.ShouldBe(Ts); + } + + /// An for a SourceNodeId not in the + /// alarm map produces NO (unknown-ref drop). + [Fact] + public void Unknown_alarm_ref_produces_no_AlarmStateUpdate() + { + var db = NewInMemoryDbFactory(); + var deploymentId = SeedDeploymentWithAlarmTag(db, RevA, + Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi"); + + var (actor, publish) = SpawnHostAndApply(db, deploymentId); + + actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( + new StubAlarmHandle(), + SourceNodeId: "NoSuch.Alarm", + ConditionId: "cond-x", + AlarmType: "OffNormalAlarm", + Message: "nope", + Severity: AlarmSeverity.Low, + SourceTimestampUtc: Ts, + Kind: AlarmTransitionKind.Raise))); + + // No alarm-condition NodeId for ("drv-1","NoSuch.Alarm") → nothing reaches the sink. + publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); + } + + /// Spawns the host with a publish probe, dispatches the deployment, and waits for the Applied + /// ACK so the apply (and thus the alarm-map build in PushDesiredSubscriptions) has completed before the + /// test publishes an alarm. A VirtualTag-host probe is injected so the real host isn't spawned. + private (IActorRef Actor, Akka.TestKit.TestProbe Publish) SpawnHostAndApply( + IDbContextFactory db, DeploymentId deploymentId) + { + var coordinator = CreateTestProbe(); + var publish = CreateTestProbe(); + var mux = CreateTestProbe(); + var vtHost = CreateTestProbe(); + + var actor = Sys.ActorOf(DriverHostActor.Props( + db, TestNode, coordinator.Ref, + localRoles: new HashSet { "driver" }, + dependencyMux: mux.Ref, + opcUaPublishActor: publish.Ref, + virtualTagEvaluator: NullVirtualTagEvaluator.Instance, + virtualTagHostOverride: vtHost.Ref)); + + actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); + coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied); + + // RebuildAddressSpace also lands on the publish probe during apply; drain it so the test's + // ExpectMsg assertions see only alarm updates. + publish.ExpectMsg(TimeSpan.FromSeconds(5)); + + return (actor, publish); + } + + /// + /// Seeds a Sealed deployment whose artifact carries one alarm-bearing equipment tag: the tag's + /// TagConfig carries both a FullName and an alarm object + /// (alarmType + severity) so DeploymentArtifact.ExtractTagAlarm projects a + /// non-null EquipmentTagAlarmInfo (here OffNormalAlarm / 700) — making the tag a + /// condition rather than a value variable. + /// + private static DeploymentId SeedDeploymentWithAlarmTag( + IDbContextFactory db, RevisionHash rev, + string Equip, string Driver, string FullName, string? Folder, string Name) + { + var artifact = JsonSerializer.SerializeToUtf8Bytes(new + { + Namespaces = new[] + { + new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0 + }, + DriverInstances = new[] + { + new { DriverInstanceId = Driver, NamespaceId = "ns-eq" }, + }, + Tags = new[] + { + new + { + TagId = "tag-0", + EquipmentId = Equip, + DriverInstanceId = Driver, + Name, + FolderPath = Folder, + DataType = "Boolean", + TagConfig = JsonSerializer.Serialize(new + { + FullName, + alarm = new { alarmType = "OffNormalAlarm", severity = 700 }, + }), + }, + }, + }); + + var id = DeploymentId.NewId(); + using var ctx = db.CreateDbContext(); + ctx.Deployments.Add(new Deployment + { + DeploymentId = id.Value, + RevisionHash = rev.Value, + Status = DeploymentStatus.Sealed, + CreatedBy = "test", + SealedAtUtc = DateTime.UtcNow, + ArtifactBlob = artifact, + }); + ctx.SaveChanges(); + return id; + } + + /// Minimal for building . + private sealed class StubAlarmHandle : IAlarmSubscriptionHandle + { + /// Gets the diagnostic ID of the alarm subscription. + public string DiagnosticId => "stub-alarm-sub"; + } +}