feat(alarms): DriverHostActor routes native alarm transitions to Part 9 conditions (Phase B WS-4c)
This commit is contained in:
@@ -110,6 +110,17 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
private readonly Dictionary<string, (string DriverInstanceId, string FullName)> _driverRefByNodeId =
|
private readonly Dictionary<string, (string DriverInstanceId, string FullName)> _driverRefByNodeId =
|
||||||
new(StringComparer.Ordinal);
|
new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>(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.</summary>
|
||||||
|
private readonly Dictionary<(string DriverInstanceId, string FullName), HashSet<string>> _alarmNodeIdByDriverRef = new();
|
||||||
|
|
||||||
|
/// <summary>Derives a full Part 9 condition snapshot from each native alarm transition delta,
|
||||||
|
/// tracking per-condition-NodeId prior state. <see cref="Clear"/>'d on every apply alongside the
|
||||||
|
/// value maps so stale condition state never leaks across redeploys.</summary>
|
||||||
|
private readonly NativeAlarmProjector _nativeAlarmProjector = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cached local <see cref="RedundancyRole"/> from the latest <see cref="RedundancyStateChanged"/>
|
/// Cached local <see cref="RedundancyRole"/> from the latest <see cref="RedundancyStateChanged"/>
|
||||||
/// snapshot (null = unknown until the first snapshot arrives, or no local node match). The inbound
|
/// 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<DispatchDeployment>(HandleDispatchFromSteady);
|
Receive<DispatchDeployment>(HandleDispatchFromSteady);
|
||||||
Receive<GetDiagnostics>(HandleGetDiagnostics);
|
Receive<GetDiagnostics>(HandleGetDiagnostics);
|
||||||
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
|
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
|
||||||
|
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
||||||
Receive<RestartDriver>(HandleRestartDriver);
|
Receive<RestartDriver>(HandleRestartDriver);
|
||||||
Receive<ReconnectDriver>(HandleReconnectDriver);
|
Receive<ReconnectDriver>(HandleReconnectDriver);
|
||||||
Receive<RouteNodeWrite>(HandleRouteNodeWrite);
|
Receive<RouteNodeWrite>(HandleRouteNodeWrite);
|
||||||
@@ -429,6 +441,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
});
|
});
|
||||||
Receive<GetDiagnostics>(HandleGetDiagnostics);
|
Receive<GetDiagnostics>(HandleGetDiagnostics);
|
||||||
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
|
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
|
||||||
|
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
||||||
Receive<RestartDriver>(HandleRestartDriver);
|
Receive<RestartDriver>(HandleRestartDriver);
|
||||||
Receive<ReconnectDriver>(HandleReconnectDriver);
|
Receive<ReconnectDriver>(HandleReconnectDriver);
|
||||||
Receive<RouteNodeWrite>(HandleRouteNodeWrite);
|
Receive<RouteNodeWrite>(HandleRouteNodeWrite);
|
||||||
@@ -466,6 +479,36 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Routes a native alarm transition (published by a driver child as
|
||||||
|
/// <see cref="DriverInstanceActor.AttributeAlarmPublished"/>) to its materialised Part 9 condition
|
||||||
|
/// node(s). The alarm path analogue of <see cref="ForwardToMux"/>: the driver fires keyed by the
|
||||||
|
/// alarm source's <c>SourceNodeId</c> (the equipment tag's wire-ref FullName), which the
|
||||||
|
/// <see cref="_alarmNodeIdByDriverRef"/> 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 <see cref="_nativeAlarmProjector"/> projects the transition delta into a full
|
||||||
|
/// <c>AlarmConditionSnapshot</c>, then this Tells <see cref="ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmStateUpdate"/>
|
||||||
|
/// — the SAME message scripted alarms use, so it routes through <c>WriteAlarmCondition</c>. An
|
||||||
|
/// unknown ref is Debug-logged and dropped (mirrors the value drop). The <c>/alerts</c> fan-out is a
|
||||||
|
/// separate concern (Task 7) and is NOT emitted here.
|
||||||
|
/// </summary>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Routes an inbound operator write (Task 11 Asks this from the OPC UA node-manager side) to the
|
/// Routes an inbound operator write (Task 11 Asks this from the OPC UA node-manager side) to the
|
||||||
/// owning driver child. Order matters:
|
/// owning driver child. Order matters:
|
||||||
@@ -731,7 +774,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
return;
|
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
|
var refsByDriver = composition.EquipmentTags
|
||||||
|
.Where(t => t.Alarm is null)
|
||||||
.GroupBy(t => t.DriverInstanceId, StringComparer.Ordinal)
|
.GroupBy(t => t.DriverInstanceId, StringComparer.Ordinal)
|
||||||
.ToDictionary(
|
.ToDictionary(
|
||||||
g => g.Key,
|
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
|
// 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.
|
// attribute), so last-writer-wins on the rare duplicate is harmless.
|
||||||
_driverRefByNodeId.Clear();
|
_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)
|
foreach (var t in composition.EquipmentTags)
|
||||||
{
|
{
|
||||||
var key = (t.DriverInstanceId, t.FullName);
|
var key = (t.DriverInstanceId, t.FullName);
|
||||||
var nodeId = EquipmentNodeIds.Variable(t.EquipmentId, t.FolderPath, t.Name);
|
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<string>(StringComparer.Ordinal);
|
||||||
|
aset.Add(nodeId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!_nodeIdByDriverRef.TryGetValue(key, out var set))
|
if (!_nodeIdByDriverRef.TryGetValue(key, out var set))
|
||||||
_nodeIdByDriverRef[key] = set = new HashSet<string>(StringComparer.Ordinal);
|
_nodeIdByDriverRef[key] = set = new HashSet<string>(StringComparer.Ordinal);
|
||||||
set.Add(nodeId);
|
set.Add(nodeId);
|
||||||
|
|||||||
+192
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the equipment-tag <b>native-alarm routing</b> wired into <see cref="DriverHostActor"/>
|
||||||
|
/// (Phase B WS-4c, the LIVE-CONDITION half): a driver child publishes a native alarm transition as
|
||||||
|
/// <see cref="DriverInstanceActor.AttributeAlarmPublished"/> keyed by the alarm source's
|
||||||
|
/// <c>SourceNodeId</c> (the equipment tag's wire-ref <c>FullName</c>), but the materialised condition
|
||||||
|
/// lives at a FOLDER-SCOPED NodeId (<c>{equipmentId}/{folderPath}/{name}</c>). After an apply, the
|
||||||
|
/// host's <c>_alarmNodeIdByDriverRef</c> map (built only from alarm-bearing EquipmentTags) resolves
|
||||||
|
/// <c>(DriverInstanceId, SourceNodeId)</c> to that NodeId, the <c>NativeAlarmProjector</c> projects
|
||||||
|
/// the transition into a full <c>AlarmConditionSnapshot</c>, and <c>ForwardNativeAlarm</c> Tells the
|
||||||
|
/// publish actor an <see cref="OpcUaPublishActor.AlarmStateUpdate"/> — the same message scripted
|
||||||
|
/// alarms use.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Mirrors the value-routing harness in <c>DriverHostActorLiveValueTests</c>: the seeded artifact
|
||||||
|
/// carries the <c>Namespaces</c> / <c>DriverInstances</c> / <c>Tags</c> arrays, with each alarm
|
||||||
|
/// tag's <c>TagConfig</c> carrying an <c>alarm</c> object so
|
||||||
|
/// <c>DeploymentArtifact.ExtractTagAlarm</c> projects a non-null
|
||||||
|
/// <c>EquipmentTagAlarmInfo</c>. The OPC UA sink + dependency mux are injected as TestProbes.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>A native alarm RAISE published by SourceNodeId (the alarm tag's FullName) lands on the
|
||||||
|
/// condition's folder-scoped NodeId (here <c>eq-1/temp_hi</c>) as an
|
||||||
|
/// <see cref="OpcUaPublishActor.AlarmStateUpdate"/> with <c>State.Active == true</c>.</summary>
|
||||||
|
[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<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
|
||||||
|
update.AlarmNodeId.ShouldBe("eq-1/temp_hi");
|
||||||
|
update.State.Active.ShouldBeTrue();
|
||||||
|
update.State.Acknowledged.ShouldBeFalse();
|
||||||
|
update.TimestampUtc.ShouldBe(Ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An <see cref="DriverInstanceActor.AttributeAlarmPublished"/> for a SourceNodeId not in the
|
||||||
|
/// alarm map produces NO <see cref="OpcUaPublishActor.AlarmStateUpdate"/> (unknown-ref drop).</summary>
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
private (IActorRef Actor, Akka.TestKit.TestProbe Publish) SpawnHostAndApply(
|
||||||
|
IDbContextFactory<OtOpcUaConfigDbContext> 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<string> { "driver" },
|
||||||
|
dependencyMux: mux.Ref,
|
||||||
|
opcUaPublishActor: publish.Ref,
|
||||||
|
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
|
||||||
|
virtualTagHostOverride: vtHost.Ref));
|
||||||
|
|
||||||
|
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
||||||
|
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||||||
|
|
||||||
|
// RebuildAddressSpace also lands on the publish probe during apply; drain it so the test's
|
||||||
|
// ExpectMsg<AlarmStateUpdate> assertions see only alarm updates.
|
||||||
|
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
return (actor, publish);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds a Sealed deployment whose artifact carries one alarm-bearing equipment tag: the tag's
|
||||||
|
/// <c>TagConfig</c> carries both a <c>FullName</c> and an <c>alarm</c> object
|
||||||
|
/// (<c>alarmType</c> + <c>severity</c>) so <c>DeploymentArtifact.ExtractTagAlarm</c> projects a
|
||||||
|
/// non-null <c>EquipmentTagAlarmInfo</c> (here <c>OffNormalAlarm</c> / 700) — making the tag a
|
||||||
|
/// condition rather than a value variable.
|
||||||
|
/// </summary>
|
||||||
|
private static DeploymentId SeedDeploymentWithAlarmTag(
|
||||||
|
IDbContextFactory<OtOpcUaConfigDbContext> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Minimal <see cref="IAlarmSubscriptionHandle"/> for building <see cref="AlarmEventArgs"/>.</summary>
|
||||||
|
private sealed class StubAlarmHandle : IAlarmSubscriptionHandle
|
||||||
|
{
|
||||||
|
/// <summary>Gets the diagnostic ID of the alarm subscription.</summary>
|
||||||
|
public string DiagnosticId => "stub-alarm-sub";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user