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)); + } +}