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 4d8954f7..a8f2a737 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
@@ -3,6 +3,8 @@ using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
@@ -14,7 +16,10 @@ 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.Core.ScriptedAlarms;
+using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
+using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
using CommonsNodeId = ZB.MOM.WW.OtOpcUa.Commons.Types.NodeId;
@@ -56,6 +61,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
private readonly IDriverHealthPublisher _healthPublisher;
private readonly IVirtualTagEvaluator _virtualTagEvaluator;
private readonly IActorRef? _virtualTagHostOverride;
+ private readonly ILoggerFactory _loggerFactory;
+ private readonly ScriptRootLogger? _scriptRootLogger;
+ private readonly IActorRef? _scriptedAlarmHostOverride;
private readonly ILoggingAdapter _log = Context.GetLogger();
/// The single VirtualTag-host child that spawns/reconciles Equipment-namespace
@@ -64,6 +72,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// from .
private IActorRef? _virtualTagHost;
+ /// The single ScriptedAlarm-host child that owns the per-node
+ /// , feeds it live tag values, and bridges its emissions onto the
+ /// OPC UA publish actor + the cluster alerts topic. Spawned in
+ /// alongside the VirtualTag host (requires both an OPC UA publish actor and a
+ /// ); receives
+ /// from .
+ private IActorRef? _scriptedAlarmHost;
+
private RevisionHash? _currentRevision;
private DeploymentId? _applyingDeploymentId;
@@ -103,6 +119,16 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// VirtualTag host instead of spawning a real child, so tests
/// can intercept the message. Null in
/// production (the real host is spawned).
+ /// Optional logger factory used to create the
+ /// 's logger when spawning the ScriptedAlarm host;
+ /// defaults to when not provided.
+ /// Optional root script logger required to spawn the ScriptedAlarm
+ /// host (the engine + its script logging hang off it). When null the ScriptedAlarm host is left
+ /// unspawned — the graceful dev/None-deployment path.
+ /// Test seam: when supplied, this actor is used as the
+ /// ScriptedAlarm host instead of spawning a real child, so
+ /// tests can intercept the message. Null
+ /// in production (the real host is spawned).
public static Props Props(
IDbContextFactory dbFactory,
CommonsNodeId localNode,
@@ -113,10 +139,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
IActorRef? opcUaPublishActor = null,
IDriverHealthPublisher? healthPublisher = null,
IVirtualTagEvaluator? virtualTagEvaluator = null,
- IActorRef? virtualTagHostOverride = null) =>
+ IActorRef? virtualTagHostOverride = null,
+ ILoggerFactory? loggerFactory = null,
+ ScriptRootLogger? scriptRootLogger = null,
+ IActorRef? scriptedAlarmHostOverride = null) =>
Akka.Actor.Props.Create(() => new DriverHostActor(
dbFactory, localNode, coordinator, driverFactory, localRoles, dependencyMux, opcUaPublishActor,
- healthPublisher, virtualTagEvaluator, virtualTagHostOverride));
+ healthPublisher, virtualTagEvaluator, virtualTagHostOverride,
+ loggerFactory, scriptRootLogger, scriptedAlarmHostOverride));
/// Initializes a new DriverHostActor with the specified dependencies.
/// Database context factory for configuration database access.
@@ -131,6 +161,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// defaults to .
/// Test seam: when supplied, used as the VirtualTag host
/// instead of spawning a real child.
+ /// Optional logger factory used to create the
+ /// 's logger; defaults to .
+ /// Optional root script logger required to spawn the ScriptedAlarm
+ /// host; when null the host is left unspawned.
+ /// Test seam: when supplied, used as the ScriptedAlarm host
+ /// instead of spawning a real child.
public DriverHostActor(
IDbContextFactory dbFactory,
CommonsNodeId localNode,
@@ -141,7 +177,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
IActorRef? opcUaPublishActor = null,
IDriverHealthPublisher? healthPublisher = null,
IVirtualTagEvaluator? virtualTagEvaluator = null,
- IActorRef? virtualTagHostOverride = null)
+ IActorRef? virtualTagHostOverride = null,
+ ILoggerFactory? loggerFactory = null,
+ ScriptRootLogger? scriptRootLogger = null,
+ IActorRef? scriptedAlarmHostOverride = null)
{
_dbFactory = dbFactory;
_localNode = localNode;
@@ -153,6 +192,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance;
_virtualTagEvaluator = virtualTagEvaluator ?? NullVirtualTagEvaluator.Instance;
_virtualTagHostOverride = virtualTagHostOverride;
+ _loggerFactory = loggerFactory ?? NullLoggerFactory.Instance;
+ _scriptRootLogger = scriptRootLogger;
+ _scriptedAlarmHostOverride = scriptedAlarmHostOverride;
// Default behavior is Steady — PreStart may flip to Stale or replay an orphan apply.
Become(Steady);
@@ -168,6 +210,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
// Spawn the VirtualTag host BEFORE Bootstrap so the bootstrap-restore path (which routes
// through PushDesiredSubscriptions and Tells ApplyVirtualTags) has a live host to target.
SpawnVirtualTagHost();
+ // Same rationale for the ScriptedAlarm host — the bootstrap-restore path Tells it
+ // ApplyScriptedAlarms through the same PushDesiredSubscriptions pass.
+ SpawnScriptedAlarmHost();
Bootstrap();
}
@@ -199,6 +244,46 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
"virtual-tag-host");
}
+ ///
+ /// Spawns the single child that owns the per-node
+ /// , feeds it live tag values from the dependency mux, and
+ /// bridges its emissions onto the OPC UA publish actor + the cluster alerts topic. A
+ /// test-supplied scriptedAlarmHostOverride short-circuits the spawn so a probe can
+ /// intercept . The real host needs
+ /// both a non-null (the emission sink) and a non-null
+ /// (the engine + its script logging hang off it); when either
+ /// is missing (legacy ControlPlane test harnesses, dev/None deployments) the host is left
+ /// null and ApplyScriptedAlarms becomes a no-op. The engine is built around a fresh
+ /// + an ;
+ /// the host (spawned as a child) owns + disposes the engine in its PostStop, so it stops with
+ /// the driver host.
+ ///
+ private void SpawnScriptedAlarmHost()
+ {
+ if (_scriptedAlarmHostOverride is not null)
+ {
+ _scriptedAlarmHost = _scriptedAlarmHostOverride;
+ return;
+ }
+
+ if (_opcUaPublishActor is null || _scriptRootLogger is null)
+ {
+ _log.Debug(
+ "DriverHost {Node}: skipping ScriptedAlarm host spawn (no publish actor / root logger)",
+ _localNode);
+ return;
+ }
+
+ var upstream = new DependencyMuxTagUpstreamSource();
+ var store = new EfAlarmConditionStateStore(
+ _dbFactory, _loggerFactory.CreateLogger());
+ var engine = new ScriptedAlarmEngine(
+ upstream, store, new ScriptLoggerFactory(_scriptRootLogger.Logger), _scriptRootLogger.Logger);
+ _scriptedAlarmHost = Context.ActorOf(
+ ScriptedAlarmHostActor.Props(_opcUaPublishActor, _dependencyMux, upstream, engine),
+ "scripted-alarm-host");
+ }
+
private void Bootstrap()
{
// Read the most-recent NodeDeploymentState for this node; if it's Applied, jump
@@ -535,6 +620,17 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_log.Info("DriverHost {Node}: applied {Count} Equipment VirtualTag(s) to the VirtualTag host",
_localNode, composition.EquipmentVirtualTags.Count);
}
+
+ // Same pass for Equipment-namespace ScriptedAlarms: hand the plans to the host so it
+ // (re)loads its engine + re-registers mux interest for the union of dependency refs. Covers
+ // both the fresh-apply and bootstrap-restore paths (both call this method); the Stale-recovery
+ // path deliberately does not, matching driver + VirtualTag recovery.
+ _scriptedAlarmHost?.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(composition.EquipmentScriptedAlarms));
+ if (composition.EquipmentScriptedAlarms.Count > 0)
+ {
+ _log.Info("DriverHost {Node}: applied {Count} Equipment ScriptedAlarm(s) to the ScriptedAlarm host",
+ _localNode, composition.EquipmentScriptedAlarms.Count);
+ }
}
private void SpawnChild(DriverInstanceSpec spec)
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs
index d17adae3..93301836 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs
@@ -11,6 +11,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
+using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Health;
@@ -90,6 +91,10 @@ public static class ServiceCollectionExtensions
var serviceLevel = resolver.GetService() ?? NullServiceLevelPublisher.Instance;
var loggerFactory = resolver.GetService() ?? NullLoggerFactory.Instance;
var healthPublisher = resolver.GetService() ?? NullDriverHealthPublisher.Instance;
+ // Root script logger backs the ScriptedAlarm host's engine + script logging. Registered in
+ // Host DI inside the hasDriver block; may be absent in some role configs / test harnesses,
+ // in which case the DriverHostActor gracefully skips spawning the ScriptedAlarm host.
+ var scriptRootLogger = resolver.GetService();
// Production evaluator is the Host's RoslynVirtualTagEvaluator (registered as
// IVirtualTagEvaluator); fall back to the null evaluator for test harnesses that don't
// register one (VirtualTagActor children then evaluate to nothing).
@@ -131,7 +136,9 @@ public static class ServiceCollectionExtensions
dependencyMux: mux,
opcUaPublishActor: publishActor,
healthPublisher: healthPublisher,
- virtualTagEvaluator: virtualTagEvaluator),
+ virtualTagEvaluator: virtualTagEvaluator,
+ loggerFactory: loggerFactory,
+ scriptRootLogger: scriptRootLogger),
DriverHostActorName);
registry.Register(driverHost);
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorTests.cs
index cfcee34e..7dfc2fc3 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorTests.cs
@@ -1,3 +1,4 @@
+using System.Text.Json;
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Shouldly;
@@ -7,6 +8,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
+using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
@@ -126,6 +128,84 @@ public sealed class DriverHostActorTests : RuntimeActorTestBase
.Status.ShouldBe(NodeDeploymentStatus.Applied);
}
+ /// Fresh apply: dispatching a deployment whose artifact carries one Equipment
+ /// ScriptedAlarm forwards a carrying that
+ /// plan to the injected ScriptedAlarm host (via the scriptedAlarmHostOverride seam, mirroring
+ /// the VirtualTag-host wiring).
+ [Fact]
+ public void Apply_forwards_EquipmentScriptedAlarms_to_scripted_alarm_host()
+ {
+ var db = NewInMemoryDbFactory();
+ var deploymentId = SeedDeploymentWithScriptedAlarm(db, RevA);
+
+ var coordinator = CreateTestProbe();
+ var alarmHost = CreateTestProbe();
+ var actor = Sys.ActorOf(DriverHostActor.Props(
+ db, TestNode, coordinator.Ref,
+ localRoles: new HashSet { "driver" },
+ scriptedAlarmHostOverride: alarmHost.Ref));
+
+ actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
+
+ coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
+
+ var apply = alarmHost.ExpectMsg(TimeSpan.FromSeconds(5));
+ var plan = apply.Plans.ShouldHaveSingleItem();
+ plan.ScriptedAlarmId.ShouldBe("al-1");
+ plan.EquipmentId.ShouldBe("eq-1");
+ plan.Name.ShouldBe("Overheat");
+ plan.PredicateSource.ShouldContain("ctx.GetTag");
+ }
+
+ private static DeploymentId SeedDeploymentWithScriptedAlarm(
+ IDbContextFactory db, RevisionHash rev)
+ {
+ // Artifact carries a ScriptedAlarm joined (by PredicateScriptId) to its predicate Script —
+ // the same shape ConfigComposer emits and DeploymentArtifact.ParseComposition decodes into
+ // composition.EquipmentScriptedAlarms.
+ var artifact = JsonSerializer.SerializeToUtf8Bytes(new
+ {
+ Scripts = new[]
+ {
+ new
+ {
+ ScriptId = "scr-1",
+ SourceCode = "return System.Convert.ToDouble(ctx.GetTag(\"Mach1.Temp\").Value) > 80;",
+ },
+ },
+ ScriptedAlarms = new[]
+ {
+ new
+ {
+ ScriptedAlarmId = "al-1",
+ EquipmentId = "eq-1",
+ Name = "Overheat",
+ AlarmType = "LimitAlarm",
+ Severity = 700,
+ MessageTemplate = "Machine 1 hot",
+ PredicateScriptId = "scr-1",
+ HistorizeToAveva = true,
+ Retain = true,
+ Enabled = true,
+ },
+ },
+ });
+
+ var id = DeploymentId.NewId();
+ using var ctx = db.CreateDbContext();
+ ctx.Deployments.Add(new Configuration.Entities.Deployment
+ {
+ DeploymentId = id.Value,
+ RevisionHash = rev.Value,
+ Status = DeploymentStatus.Sealed,
+ CreatedBy = "test",
+ SealedAtUtc = DateTime.UtcNow,
+ ArtifactBlob = artifact,
+ });
+ ctx.SaveChanges();
+ return id;
+ }
+
private static DeploymentId SeedDeployment(
IDbContextFactory db,
RevisionHash rev,