diff --git a/docs/VirtualTags.md b/docs/VirtualTags.md index e31943de..0ca700cc 100644 --- a/docs/VirtualTags.md +++ b/docs/VirtualTags.md @@ -229,6 +229,7 @@ In the v2 actor system, virtual-tag engine composition is owned by the driver-ro - `AddressSpaceComposer.Compose` emits `DriverInstancePlan` / `ScriptedAlarmPlan` records; the driver-role `DriverHostActor` spawns one `VirtualTagActor` per virtual-tag expression and one `ScriptedAlarmActor` per scripted alarm. - `RoslynVirtualTagEvaluator` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs`) is injected into each `VirtualTagActor` as its `IVirtualTagEvaluator`. It holds a per-source `CompiledScriptCache` keyed by script source and compiles on first use. +- **Disabled virtual tags (`VirtualTag.Enabled = false`, lmxopcua#787).** Both the composer and the artifact decode (`DeploymentArtifact.BuildEquipmentVirtualTagPlans`, byte-parity) drop a disabled row from `EquipmentVirtualTags`, so it is neither materialised as a node nor spawned/evaluated. This matches disabled scripted alarms (not materialised). Consequences: a client bound to the node (e.g. ScadaBridge) gets `BadNodeIdUnknown`, and a script that reads it via `ctx.GetTag` sees a Bad/missing upstream; re-enabling and redeploying restores it. `DraftValidator` exempts a disabled VT from `VirtualTagNoEffectiveTrigger` (it never evaluates, so it must not block a deploy); its name still counts toward the equipment name-uniqueness check. Calculation tags have no `Enabled` flag, so there is no equivalent gap. - `DependencyMuxActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs`) receives every `DriverInstanceActor.AttributeValueBatchPublished` and routes each item to the `VirtualTagActor` instances that registered interest in that tag ref. `ITagUpstreamSource` and `IHistoryWriter` remain available as `Core.VirtualTags` ports. diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs index a4ac7452..7a94dc25 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs @@ -493,6 +493,8 @@ public static class DraftValidator .ToDictionary(g => g.Key, g => g.First().SourceCode, StringComparer.Ordinal); foreach (var v in draft.VirtualTags) { + // A disabled VirtualTag is never deployed or evaluated (lmxopcua#787), so it can't be stuck waiting. + if (!v.Enabled) continue; if (v.TimerIntervalMs is > 0) continue; var source = scriptSourceById.TryGetValue(v.ScriptId, out var s) ? s : null; // An unresolved script is reported elsewhere; with no source there are no reads to count. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs index dfd0de72..e58904a1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs @@ -615,7 +615,10 @@ public static class AddressSpaceComposer // DependencyRefs = the distinct ctx.GetTag("…") literals the VirtualTagActor subscribes to. // VirtualTag has no FolderPath today → "". var scriptsById = resolvedScripts.ToDictionary(s => s.ScriptId, StringComparer.Ordinal); + // A disabled VirtualTag (lmxopcua#787) is not part of the runtime plan: no node, no evaluation — + // the same as a disabled scripted alarm (which is not materialised). Mirrored by the artifact decode. var equipmentVirtualTags = vtags + .Where(v => v.Enabled) .OrderBy(v => v.EquipmentId, StringComparer.Ordinal) .ThenBy(v => v.Name, StringComparer.Ordinal) .Select(v => diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs index 0de7dc7f..eade1c6f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -1405,6 +1405,10 @@ internal sealed class ParsedDeploymentArtifact : IDisposable if (string.IsNullOrWhiteSpace(virtualTagId) || string.IsNullOrWhiteSpace(equipmentId) || string.IsNullOrWhiteSpace(name)) continue; + // Enabled (lmxopcua#787): a disabled VirtualTag is not deployed or evaluated — byte-parity with + // AddressSpaceComposer.Compose's `.Where(v => v.Enabled)`. Absent/non-bool => the entity default (true). + if (el.TryGetProperty("Enabled", out var enEl) && enEl.ValueKind == JsonValueKind.False) continue; + var source = scriptId is not null && scriptSourceById.TryGetValue(scriptId, out var src) ? src : string.Empty; diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorVirtualTagTriggerTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorVirtualTagTriggerTests.cs index d6548a4d..1ffd9a15 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorVirtualTagTriggerTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorVirtualTagTriggerTests.cs @@ -45,6 +45,16 @@ public sealed class DraftValidatorVirtualTagTriggerTests public void A_literal_read_with_change_trigger_is_an_effective_trigger() => Run(Vt(true, null), "return (double)ctx.GetTag(\"a/b\").Value;").ShouldBeEmpty(); + /// lmxopcua#787 — a disabled VirtualTag is never deployed or evaluated, so it must not block a + /// deploy for lacking an effective trigger. + [Fact] + public void A_disabled_virtual_tag_is_exempt() + { + var vt = Vt(true, null); + vt.Enabled = false; + Run(vt, "return 1;").ShouldBeEmpty(); + } + [Fact] public void An_unresolved_script_reports_no_trigger_error() { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactVirtualTagHistorizeParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactVirtualTagHistorizeParityTests.cs index d6d28f79..116f50ae 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactVirtualTagHistorizeParityTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactVirtualTagHistorizeParityTests.cs @@ -163,6 +163,36 @@ public sealed class DeploymentArtifactVirtualTagHistorizeParityTests d.ChangeTriggered.ShouldBeTrue(); } + /// lmxopcua#787 — a disabled VirtualTag is dropped by both the artifact decode and the composer + /// (byte-parity), so it is neither deployed nor evaluated; absent/non-bool Enabled keeps the default true. + [Fact] + public void ParseComposition_drops_disabled_virtual_tag_in_parity_with_composer() + { + var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" }; + var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" }; + var equip = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "M1", MachineCode = "M1" }; + var script = new Script { ScriptId = "s-1", Name = "p", SourceCode = "return 1;", SourceHash = "h" }; + var on = new VirtualTag { VirtualTagId = "vt-on", EquipmentId = "eq-1", Name = "On", DataType = "Int32", ScriptId = "s-1", TimerIntervalMs = 1000 }; + var off = new VirtualTag { VirtualTagId = "vt-off", EquipmentId = "eq-1", Name = "Off", DataType = "Int32", ScriptId = "s-1", TimerIntervalMs = 1000, Enabled = false }; + + var composed = AddressSpaceComposer.Compose( + new[] { area }, new[] { line }, new[] { equip }, + Array.Empty(), Array.Empty(), + virtualTags: new[] { on, off }, scripts: new[] { script }); + + var blob = BlobOf(new + { + Namespaces = new[] { new { NamespaceId = "ns-eq", Kind = 0 } }, + Scripts = new[] { new { ScriptId = "s-1", SourceCode = "return 1;" } }, + VirtualTags = new[] { ToSnapshot(on), ToSnapshot(off) }, + }); + var decoded = DeploymentArtifact.ParseComposition(blob); + + composed.EquipmentVirtualTags.ShouldHaveSingleItem().VirtualTagId.ShouldBe("vt-on"); + decoded.EquipmentVirtualTags.ShouldHaveSingleItem().VirtualTagId.ShouldBe("vt-on"); + decoded.EquipmentVirtualTags.SequenceEqual(composed.EquipmentVirtualTags).ShouldBeTrue(); + } + // The Pascal-case snapshot a VirtualTag EF entity serialises to (matches ConfigComposer's // DefaultIgnoreCondition.Never whole-entity serialisation — includes Historize). private static object ToSnapshot(VirtualTag v) => new diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorVirtualTagTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorVirtualTagTests.cs index 443996d3..ceef471c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorVirtualTagTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorVirtualTagTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Text.Json; using Akka.Actor; using Microsoft.EntityFrameworkCore; @@ -83,8 +84,31 @@ public sealed class DriverHostActorVirtualTagTests : RuntimeActorTestBase apply.Plans.ShouldHaveSingleItem().VirtualTagId.ShouldBe("vt-1"); } + /// lmxopcua#787: a deployment carrying a disabled VirtualTag next to an enabled one hands the + /// VirtualTag host ONLY the enabled plan — the disabled tag is never spawned, so never evaluated. + [Fact] + public void Apply_does_not_forward_disabled_VirtualTag_to_virtual_tag_host() + { + var db = NewInMemoryDbFactory(); + var deploymentId = SeedDeploymentWithVirtualTag(db, RevA, withDisabledSibling: true); + + var coordinator = CreateTestProbe(); + var vtHost = CreateTestProbe(); + var actor = Sys.ActorOf(DriverHostActor.Props( + db, TestNode, coordinator.Ref, + localRoles: new HashSet { "driver" }, + virtualTagEvaluator: NullVirtualTagEvaluator.Instance, + virtualTagHostOverride: vtHost.Ref)); + + actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); + + coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied); + var apply = vtHost.ExpectMsg(TimeSpan.FromSeconds(5)); + apply.Plans.ShouldHaveSingleItem().VirtualTagId.ShouldBe("vt-1"); + } + private static DeploymentId SeedDeploymentWithVirtualTag( - IDbContextFactory db, RevisionHash rev) + IDbContextFactory db, RevisionHash rev, bool withDisabledSibling = false) { var artifact = JsonSerializer.SerializeToUtf8Bytes(new { @@ -105,8 +129,18 @@ public sealed class DriverHostActorVirtualTagTests : RuntimeActorTestBase Name = "Doubled", DataType = "Float", ScriptId = "scr-1", + Enabled = true, }, - }, + new + { + VirtualTagId = "vt-off", + EquipmentId = "eq-1", + Name = "Off", + DataType = "Float", + ScriptId = "scr-1", + Enabled = false, + }, + }.Take(withDisabledSibling ? 2 : 1).ToArray(), }); var id = DeploymentId.NewId();