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 462e7b65..6c8a1aea 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
@@ -21,6 +21,7 @@ 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.Core.VirtualTags;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
@@ -65,6 +66,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
private readonly IActorRef? _opcUaPublishActor;
private readonly IDriverHealthPublisher _healthPublisher;
private readonly IVirtualTagEvaluator _virtualTagEvaluator;
+ private readonly IHistoryWriter _historyWriter;
private readonly IActorRef? _virtualTagHostOverride;
private readonly ILoggerFactory _loggerFactory;
private readonly ScriptRootLogger? _scriptRootLogger;
@@ -196,6 +198,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// 's children; defaults to
/// (the dev/Mac path where no expression is evaluated). Production passes the DI-resolved
/// Roslyn evaluator.
+ /// Optional sink handed to the spawned
+ /// for VirtualTag results whose plan opted into Historize=true; defaults to
+ /// (the durable AVEVA sink is infra-gated, so no live-data historian
+ /// write RPC exists). A deployment that binds a real in DI overrides it.
/// Test seam: when supplied, this actor is used as the
/// VirtualTag host instead of spawning a real child, so tests
/// can intercept the message. Null in
@@ -220,13 +226,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
IActorRef? opcUaPublishActor = null,
IDriverHealthPublisher? healthPublisher = null,
IVirtualTagEvaluator? virtualTagEvaluator = null,
+ IHistoryWriter? historyWriter = 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, historyWriter, virtualTagHostOverride,
loggerFactory, scriptRootLogger, scriptedAlarmHostOverride));
/// Initializes a new DriverHostActor with the specified dependencies.
@@ -240,6 +247,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// Optional driver-health publisher; defaults to .
/// Optional evaluator handed to the VirtualTag host's children;
/// defaults to .
+ /// Optional sink handed to the spawned
+ /// for historized VirtualTag results; defaults to .
/// Test seam: when supplied, used as the VirtualTag host
/// instead of spawning a real child.
/// Optional logger factory used to create the
@@ -258,6 +267,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
IActorRef? opcUaPublishActor = null,
IDriverHealthPublisher? healthPublisher = null,
IVirtualTagEvaluator? virtualTagEvaluator = null,
+ IHistoryWriter? historyWriter = null,
IActorRef? virtualTagHostOverride = null,
ILoggerFactory? loggerFactory = null,
ScriptRootLogger? scriptRootLogger = null,
@@ -272,6 +282,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_opcUaPublishActor = opcUaPublishActor;
_healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance;
_virtualTagEvaluator = virtualTagEvaluator ?? NullVirtualTagEvaluator.Instance;
+ _historyWriter = historyWriter ?? NullHistoryWriter.Instance;
_virtualTagHostOverride = virtualTagHostOverride;
_loggerFactory = loggerFactory ?? NullLoggerFactory.Instance;
_scriptRootLogger = scriptRootLogger;
@@ -329,7 +340,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
}
_virtualTagHost = Context.ActorOf(
- VirtualTagHostActor.Props(_opcUaPublishActor, _dependencyMux, _virtualTagEvaluator),
+ VirtualTagHostActor.Props(_opcUaPublishActor, _dependencyMux, _virtualTagEvaluator, _historyWriter),
"virtual-tag-host");
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs
index c6aa75c6..dea78446 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs
@@ -13,6 +13,7 @@ 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.Core.VirtualTags;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Health;
@@ -43,6 +44,10 @@ public static class ServiceCollectionExtensions
{
services.TryAddSingleton(NullAlarmHistorianSink.Instance);
services.TryAddSingleton(NullHistorianDataSource.Instance);
+ // VirtualTag historization sink. Null default — the durable AVEVA sink is infra-gated (there is
+ // no live-data historian write RPC). TryAddSingleton so a deployment that bound a real
+ // IHistoryWriter earlier wins.
+ services.TryAddSingleton(NullHistoryWriter.Instance);
services.TryAddSingleton(NullDriverFactory.Instance);
services.TryAddSingleton(NullOpcUaAddressSpaceSink.Instance);
services.TryAddSingleton(NullServiceLevelPublisher.Instance);
@@ -184,6 +189,10 @@ public static class ServiceCollectionExtensions
virtualTagEvaluator = NullVirtualTagEvaluator.Instance;
}
+ // VirtualTag historization sink threaded to the spawned VirtualTagHostActor. Null default
+ // (durable AVEVA sink is infra-gated); a deployment binding a real IHistoryWriter overrides.
+ var historyWriter = resolver.GetService() ?? NullHistoryWriter.Instance;
+
var dbHealth = system.ActorOf(
DbHealthProbeActor.Props(dbFactory),
DbHealthProbeActorName);
@@ -215,6 +224,7 @@ public static class ServiceCollectionExtensions
opcUaPublishActor: publishActor,
healthPublisher: healthPublisher,
virtualTagEvaluator: virtualTagEvaluator,
+ historyWriter: historyWriter,
loggerFactory: loggerFactory,
scriptRootLogger: scriptRootLogger),
DriverHostActorName);
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorHistoryWriterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorHistoryWriterTests.cs
new file mode 100644
index 00000000..76869edd
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorHistoryWriterTests.cs
@@ -0,0 +1,127 @@
+using System.Collections.Concurrent;
+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.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.Core.VirtualTags;
+using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
+using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
+using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
+
+namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
+
+///
+/// H5d (DI seam): pins that accepts an
+/// and threads it through to the REAL spawned
+/// (the virtualTagHostOverride is deliberately NOT used,
+/// so the production spawn path that wires historyWriter is exercised).
+///
+///
+/// This is a construction/threading smoke test, not a record-behaviour test. The record path —
+/// "a Historize=true result is recorded with the IHistoryWriter under the folder-scoped NodeId" —
+/// is already covered end-to-end against the host directly by
+/// VirtualTagHostActorTests.Historized_vtag_result_is_recorded_with_the_history_writer (H5c).
+/// Driving a real spawned child to emit a synthetic
+/// from inside would require reaching the auto-named grandchild and a
+/// live evaluator/mux feed — heavy and brittle for no extra coverage. So here we assert the writer
+/// threads through cleanly: a real apply carrying a Historize=true Equipment VirtualTag is Applied
+/// with the real host spawned and the capturing writer wired in, with no construction error.
+///
+public sealed class DriverHostActorHistoryWriterTests : RuntimeActorTestBase
+{
+ private static readonly NodeId TestNode = NodeId.Parse("driver-hw-test");
+ private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
+
+ /// Spawning a real DriverHostActor (no host override, publish actor wired so the real
+ /// VirtualTagHostActor spawns) with a capturing IHistoryWriter applies a Historize=true VirtualTag
+ /// deployment cleanly — proving Props/ctor accept the writer and thread it through the real spawn.
+ [Fact]
+ public void Props_accepts_history_writer_and_applies_historized_vtag_deployment()
+ {
+ var db = NewInMemoryDbFactory();
+ var deploymentId = SeedDeploymentWithHistorizedVirtualTag(db, RevA);
+
+ var coordinator = CreateTestProbe();
+ // A publish actor MUST be wired for SpawnVirtualTagHost to spawn the real host (its ctor
+ // requires a non-null sink). No virtualTagHostOverride ⇒ the real VirtualTagHostActor is
+ // spawned with _historyWriter threaded in.
+ var publish = CreateTestProbe();
+ var writer = new CapturingHistoryWriter();
+
+ var actor = Sys.ActorOf(DriverHostActor.Props(
+ db, TestNode, coordinator.Ref,
+ localRoles: new HashSet { "driver" },
+ opcUaPublishActor: publish.Ref,
+ virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
+ historyWriter: writer));
+
+ actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
+
+ // The deployment Applies — the real VirtualTagHostActor spawned and accepted ApplyVirtualTags
+ // with the writer threaded through (a construction/threading failure would have faulted PreStart
+ // or the apply turn). Record behaviour itself is covered by VirtualTagHostActorTests (H5c).
+ coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
+ }
+
+ private static DeploymentId SeedDeploymentWithHistorizedVirtualTag(
+ IDbContextFactory db, RevisionHash rev)
+ {
+ var artifact = JsonSerializer.SerializeToUtf8Bytes(new
+ {
+ Scripts = new[]
+ {
+ new
+ {
+ ScriptId = "scr-1",
+ SourceCode = "return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;",
+ },
+ },
+ VirtualTags = new[]
+ {
+ new
+ {
+ VirtualTagId = "vt-1",
+ EquipmentId = "eq-1",
+ Name = "Doubled",
+ DataType = "Float",
+ ScriptId = "scr-1",
+ Historize = true,
+ },
+ },
+ });
+
+ 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;
+ }
+
+ /// Capturing : records every Record call so the threading
+ /// smoke test can be tightened later if a record assertion becomes feasible here.
+ private sealed class CapturingHistoryWriter : IHistoryWriter
+ {
+ public readonly ConcurrentQueue<(string Path, DataValueSnapshot Value)> Calls = new();
+
+ /// Captures the path + snapshot of a Record call.
+ /// The virtual tag path.
+ /// The data value snapshot.
+ public void Record(string path, DataValueSnapshot value) => Calls.Enqueue((path, value));
+ }
+}