diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs index fc6f79d5..6bc2cb7c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs @@ -2,6 +2,8 @@ using Akka.Actor; using Akka.Event; using ZB.MOM.WW.OtOpcUa.Commons.Engines; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.VirtualTags; using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; @@ -33,6 +35,9 @@ public sealed class VirtualTagHostActor : ReceiveActor private readonly IActorRef _publishActor; private readonly IActorRef? _mux; private readonly IVirtualTagEvaluator _evaluator; + // Sink for historized VirtualTag results (plans with Historize=true). NullHistoryWriter when no + // durable historian is wired, so OnResult always has a non-null target. + private readonly IHistoryWriter _history; private readonly ILoggingAdapter _log = Context.GetLogger(); // vtagId -> spawned child VirtualTagActor. @@ -50,20 +55,27 @@ public sealed class VirtualTagHostActor : ReceiveActor /// Optional dependency multiplexer; passed to each spawned child so it can /// register interest in its dependency refs. Null on the dev/Mac path (no live values). /// The evaluator each child uses to compute its expression. - public static Props Props(IActorRef publishActor, IActorRef? mux, IVirtualTagEvaluator evaluator) => - Akka.Actor.Props.Create(() => new VirtualTagHostActor(publishActor, mux, evaluator)); + /// Sink for results whose plan has Historize=true. Null ⇒ + /// (no durable historian wired), so existing call sites + /// compile unchanged and never historize. + public static Props Props(IActorRef publishActor, IActorRef? mux, IVirtualTagEvaluator evaluator, + IHistoryWriter? historyWriter = null) => + Akka.Actor.Props.Create(() => new VirtualTagHostActor(publishActor, mux, evaluator, historyWriter)); /// Initializes a new instance of the class. /// The OPC UA publish actor results are bridged to. /// Optional dependency multiplexer passed to each spawned child. /// The evaluator each child uses to compute its expression. - public VirtualTagHostActor(IActorRef publishActor, IActorRef? mux, IVirtualTagEvaluator evaluator) + /// Sink for historized results; null ⇒ . + public VirtualTagHostActor(IActorRef publishActor, IActorRef? mux, IVirtualTagEvaluator evaluator, + IHistoryWriter? historyWriter = null) { ArgumentNullException.ThrowIfNull(publishActor); ArgumentNullException.ThrowIfNull(evaluator); _publishActor = publishActor; _mux = mux; _evaluator = evaluator; + _history = historyWriter ?? NullHistoryWriter.Instance; Receive(OnApply); Receive(OnResult); @@ -154,6 +166,15 @@ public sealed class VirtualTagHostActor : ReceiveActor _publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate( nodeId, result.Value, OpcUaQuality.Good, result.TimestampUtc)); + + // Historize iff the plan opted in. Reuses _planByVtag (kept in lock-step with _children), so + // no parallel map. The historian path key is the SAME folder-scoped NodeId we just published + // to. For a computed value source == server, so both timestamps are the evaluation time. + if (_planByVtag.TryGetValue(result.VirtualTagId, out var plan) && plan.Historize) + { + _history.Record(nodeId, new DataValueSnapshot( + result.Value, 0u /* StatusCodes.Good */, result.TimestampUtc, result.TimestampUtc)); + } } private void OnChildTerminated(Terminated msg) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ZB.MOM.WW.OtOpcUa.Runtime.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ZB.MOM.WW.OtOpcUa.Runtime.csproj index c870ba65..98eb9cce 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ZB.MOM.WW.OtOpcUa.Runtime.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ZB.MOM.WW.OtOpcUa.Runtime.csproj @@ -32,6 +32,9 @@ + + diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs index 43461e72..76bc7396 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs @@ -4,6 +4,8 @@ using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Engines; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.VirtualTags; using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; @@ -153,6 +155,71 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase secondChild.ShouldNotBe(firstChild); } + /// A plan with an explicit Historize flag, so H5c can assert the host historizes a + /// result iff the plan opted in. Mirrors but threads . + private static EquipmentVirtualTagPlan PlanH( + string vtagId, string equipmentId, string name, bool historize, string folderPath = "") => + new( + VirtualTagId: vtagId, + EquipmentId: equipmentId, + FolderPath: folderPath, + Name: name, + DataType: "Double", + Expression: "ctx.GetTag(\"a\")", + DependencyRefs: new[] { "a" }, + Historize: historize); + + /// H5c: a result for a vtag whose plan has Historize=true is recorded with the + /// IHistoryWriter under the SAME folder-scoped NodeId the value was published to, carrying the + /// result value and OPC UA Good (0u) status — in addition to the normal publish. + [Fact] + public void Historized_vtag_result_is_recorded_with_the_history_writer() + { + var publish = CreateTestProbe(); + var writer = new CapturingHistoryWriter(); + var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator(), writer)); + + host.Tell(new VirtualTagHostActor.ApplyVirtualTags( + new[] { PlanH("vt-1", "eq-1", "speed-rpm", historize: true) })); + + var ts = new DateTime(2026, 6, 7, 12, 0, 0, DateTimeKind.Utc); + host.Tell(new VirtualTagActor.EvaluationResult("vt-1", 42.0, ts, CorrelationId.NewId())); + + // The publish still happens — historization is additive, not a replacement. + var update = publish.ExpectMsg(); + update.NodeId.ShouldBe("eq-1/speed-rpm"); + + // Wait for the history record to land (delivered on the same actor turn as the publish). + AwaitAssert(() => writer.Calls.Count.ShouldBe(1)); + var (path, value) = writer.Calls[0]; + path.ShouldBe("eq-1/speed-rpm"); + value.Value.ShouldBe(42.0); + value.StatusCode.ShouldBe(0u); // OPC UA Good + value.SourceTimestampUtc.ShouldBe(ts); + value.ServerTimestampUtc.ShouldBe(ts); + } + + /// H5c: a result for a vtag whose plan has Historize=false is NOT recorded — the writer + /// is never called — though the value is still published. + [Fact] + public void Non_historized_vtag_result_is_not_recorded() + { + var publish = CreateTestProbe(); + var writer = new CapturingHistoryWriter(); + var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator(), writer)); + + host.Tell(new VirtualTagHostActor.ApplyVirtualTags( + new[] { PlanH("vt-1", "eq-1", "speed-rpm", historize: false) })); + + host.Tell(new VirtualTagActor.EvaluationResult("vt-1", 42.0, DateTime.UtcNow, CorrelationId.NewId())); + + // The value is still published… + publish.ExpectMsg(); + // …but the historian was never touched. + publish.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); + writer.Calls.ShouldBeEmpty(); + } + /// A plan with an explicit Expression + DependencyRefs (the H1b in-place-change case). private static EquipmentVirtualTagPlan PlanWithRefs( string vtagId, string equipmentId, string name, string expression, params string[] refs) => @@ -277,6 +344,18 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase mux.LastSender.ShouldNotBe(firstChild); } + /// Capturing : records every Record call so H5c tests can + /// assert the host historizes (or does not) and with what path + snapshot. + private sealed class CapturingHistoryWriter : IHistoryWriter + { + public readonly List<(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.Add((path, value)); + } + /// Deterministic no-op evaluator: keeps spawned children inert so tests drive the host's /// OnResult path directly via synthetic EvaluationResults. private sealed class StubEvaluator : IVirtualTagEvaluator