diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs new file mode 100644 index 00000000..43c92e86 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs @@ -0,0 +1,138 @@ +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.OpcUaServer; +using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags; + +/// +/// Supervisor that gives Equipment-namespace VirtualTags live values. For each +/// in the desired set it spawns one child +/// (which self-registers with the dependency mux and evaluates its +/// expression on dependency changes) and remembers the plan's folder-scoped NodeId. When a +/// child reports a fresh , the host bridges it onto +/// an targeting that NodeId so the +/// already-materialised Variable node (currently BadWaitingForInitialData) reflects the value. +/// +/// +/// The published NodeId is computed with the identical formula +/// Phase7Applier.MaterialiseEquipmentVirtualTags uses to materialise the variable — +/// {parent}/{Name} where parent = IsNullOrWhiteSpace(FolderPath) ? EquipmentId : +/// {EquipmentId}/{FolderPath} — or the value would land on a NodeId that does not exist. +/// +/// +public sealed class VirtualTagHostActor : ReceiveActor +{ + /// Reconciles the live VirtualTag children to exactly the supplied desired set: + /// stops children whose vtagId is gone, spawns children for new vtagIds, and rebuilds the + /// vtagId→NodeId map so renames are reflected. + /// The desired Equipment-namespace VirtualTag plans. + public sealed record ApplyVirtualTags(IReadOnlyList Plans); + + private readonly IActorRef _publishActor; + private readonly IActorRef? _mux; + private readonly IVirtualTagEvaluator _evaluator; + private readonly ILoggingAdapter _log = Context.GetLogger(); + + // vtagId -> spawned child VirtualTagActor. + private readonly Dictionary _children = new(StringComparer.Ordinal); + // vtagId -> folder-scoped OPC UA NodeId the materialiser placed the variable at. + private readonly Dictionary _nodeIdByVtag = new(StringComparer.Ordinal); + + /// Factory method to create Props for a VirtualTagHostActor. + /// The OPC UA publish actor that consumes + /// bridged from child results. + /// 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)); + + /// 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) + { + ArgumentNullException.ThrowIfNull(publishActor); + ArgumentNullException.ThrowIfNull(evaluator); + _publishActor = publishActor; + _mux = mux; + _evaluator = evaluator; + + Receive(OnApply); + Receive(OnResult); + } + + private void OnApply(ApplyVirtualTags msg) + { + var desired = new HashSet(msg.Plans.Select(p => p.VirtualTagId), StringComparer.Ordinal); + + // Stop + forget children whose vtagId is no longer desired. Stopping the child triggers its + // PostStop, which unregisters its interest from the mux. + foreach (var vtagId in _children.Keys.Where(id => !desired.Contains(id)).ToList()) + { + Context.Stop(_children[vtagId]); + _children.Remove(vtagId); + } + + // Rebuild the NodeId map every apply so renames (Name/FolderPath/EquipmentId changes) are + // picked up. The map only contains currently-desired vtags, so a result for a removed vtag + // finds no entry and is dropped. + _nodeIdByVtag.Clear(); + foreach (var p in msg.Plans) + { + _nodeIdByVtag[p.VirtualTagId] = NodeIdFor(p); + } + + // Spawn children for new vtagIds only — existing children keep their mux subscriptions and + // last-value dedup state. Expression/dependency changes on an existing vtag are NOT + // re-applied here; the loader's vtags are stable, and a future enhancement can stop+respawn + // a child whose plan changed (the diff already identifies ChangedEquipmentVirtualTags). + foreach (var p in msg.Plans) + { + if (_children.ContainsKey(p.VirtualTagId)) continue; + + // Auto-name the child: vtagIds can contain characters illegal in actor names, so let Akka + // assign a safe unique name. The child self-registers with the mux in PreStart. + var child = Context.ActorOf(VirtualTagActor.Props( + virtualTagId: p.VirtualTagId, + expression: p.Expression, + evaluator: _evaluator, + scriptId: p.VirtualTagId, + publisherFactory: null, + dependencyRefs: p.DependencyRefs, + mux: _mux)); + _children[p.VirtualTagId] = child; + } + + _log.Debug("VirtualTagHost: applied (desired={Desired}, children={Children})", + desired.Count, _children.Count); + } + + private void OnResult(VirtualTagActor.EvaluationResult result) + { + // A result may arrive for a vtag that was just removed from the desired set (the child's + // last in-flight message). With no NodeId mapping we have nowhere to land it — drop silently. + if (!_nodeIdByVtag.TryGetValue(result.VirtualTagId, out var nodeId)) + { + return; + } + + _publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate( + nodeId, result.Value, OpcUaQuality.Good, result.TimestampUtc)); + } + + /// Folder-scoped NodeId for a VirtualTag plan — MUST match + /// Phase7Applier.MaterialiseEquipmentVirtualTags exactly, or the published value lands on a + /// NodeId that was never materialised. + private static string NodeIdFor(EquipmentVirtualTagPlan p) + { + var parent = string.IsNullOrWhiteSpace(p.FolderPath) + ? p.EquipmentId + : $"{p.EquipmentId}/{p.FolderPath}"; + return $"{parent}/{p.Name}"; + } +} 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 new file mode 100644 index 00000000..347d0e74 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs @@ -0,0 +1,130 @@ +using Akka.Actor; +using Shouldly; +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.OpcUaServer; +using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; +using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; +using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.VirtualTags; + +/// +/// Verifies reconciles a desired set of +/// into child s and bridges each +/// child's onto an +/// carrying the folder-scoped NodeId computed by +/// the materialiser. +/// +public sealed class VirtualTagHostActorTests : RuntimeActorTestBase +{ + /// A plan with no FolderPath maps onto NodeId "EquipmentId/Name". + private static EquipmentVirtualTagPlan Plan( + string vtagId, string equipmentId, string name, string folderPath = "") => + new( + VirtualTagId: vtagId, + EquipmentId: equipmentId, + FolderPath: folderPath, + Name: name, + DataType: "Double", + Expression: "ctx.GetTag(\"a\")", + DependencyRefs: new[] { "a" }); + + /// Spawn: an apply with one plan spins up exactly one live child VirtualTagActor. + [Fact] + public void ApplyVirtualTags_spawns_one_child_per_plan() + { + var publish = CreateTestProbe(); + var mux = CreateTestProbe(); + var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux.Ref, new StubEvaluator())); + + host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") })); + + // The child self-registers with the mux in PreStart, so a RegisterInterest landing on the + // mux probe is proof the host spawned a live child. + var reg = mux.ExpectMsg(); + reg.TagRefs.ShouldContain("a"); + } + + /// KEY TEST: a child EvaluationResult is bridged to the publish actor with the + /// folder-scoped NodeId, Value, Good quality, and source timestamp preserved. + [Fact] + public void EvaluationResult_is_bridged_with_folder_scoped_NodeId() + { + var publish = CreateTestProbe(); + var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator())); + + host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") })); + + var ts = new DateTime(2026, 6, 7, 12, 0, 0, DateTimeKind.Utc); + host.Tell(new VirtualTagActor.EvaluationResult("vt-1", 42.0, ts, CorrelationId.NewId())); + + var update = publish.ExpectMsg(); + update.NodeId.ShouldBe("eq-1/speed-rpm"); + update.Value.ShouldBe(42.0); + update.Quality.ShouldBe(OpcUaQuality.Good); + update.TimestampUtc.ShouldBe(ts); + } + + /// FolderPath is honoured in the published NodeId (EquipmentId/FolderPath/Name). + [Fact] + public void EvaluationResult_NodeId_includes_folder_path_when_set() + { + var publish = CreateTestProbe(); + var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator())); + + host.Tell(new VirtualTagHostActor.ApplyVirtualTags( + new[] { Plan("vt-1", "eq-1", "speed-rpm", folderPath: "metrics") })); + + host.Tell(new VirtualTagActor.EvaluationResult("vt-1", 1.0, DateTime.UtcNow, CorrelationId.NewId())); + + var update = publish.ExpectMsg(); + update.NodeId.ShouldBe("eq-1/metrics/speed-rpm"); + } + + /// Stop-removed: a vtag dropped from the desired set is unmapped, so a later result for + /// it produces NO publish. + [Fact] + public void Removed_vtag_is_no_longer_bridged() + { + var publish = CreateTestProbe(); + var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator())); + + host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") })); + // Re-apply without vt-1 — it should be stopped + unmapped. + host.Tell(new VirtualTagHostActor.ApplyVirtualTags(Array.Empty())); + + host.Tell(new VirtualTagActor.EvaluationResult("vt-1", 99.0, DateTime.UtcNow, CorrelationId.NewId())); + + publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); + } + + /// Unmapped result dropped: a result for an unknown vtagId is silently ignored. + [Fact] + public void Result_for_unknown_vtag_is_dropped() + { + var publish = CreateTestProbe(); + var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator())); + + host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") })); + + host.Tell(new VirtualTagActor.EvaluationResult("vt-unknown", 7.0, DateTime.UtcNow, CorrelationId.NewId())); + + publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); + } + + /// 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 + { + /// Returns NoChange so the child never emits on its own. + /// The tag identifier. + /// The expression string. + /// The dependency values. + /// A NoChange result. + public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary deps) + => VirtualTagEvalResult.NoChange; + } +}