diff --git a/docs/VirtualTags.md b/docs/VirtualTags.md
index 0ca700cc..814056d9 100644
--- a/docs/VirtualTags.md
+++ b/docs/VirtualTags.md
@@ -229,7 +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.
+- **Disabled virtual tags (`VirtualTag.Enabled = false`, lmxopcua#787, owner decision).** A disabled tag **keeps its node** but is **never evaluated**. Both seams (`AddressSpaceComposer.Compose` and the artifact decode `DeploymentArtifact.BuildEquipmentVirtualTagPlans`, byte-parity) keep the row in `EquipmentVirtualTags` with `EquipmentVirtualTagPlan.Enabled = false` (part of the plan's value equality), so the node is materialised exactly as for an enabled tag (same NodeId, read-only). `VirtualTagHostActor` spawns **no** `VirtualTagActor` for it (no mux registration, no timer, no historization) and instead publishes **`BadOutOfService` (0x808D0000) with a null value**, stamped `DateTime.UtcNow` at deploy, on every apply (a rebuild resets the node to `BadWaitingForInitialData`; the publish lands after it in the publish actor's mailbox). A client bound to the node (e.g. ScadaBridge) therefore reads a Bad quality, not `BadNodeIdUnknown`, and can tell "deliberately out of service" from "script failed" (`BadInternalError`). Writes are refused like any VirtualTag (the node is read-only). Flipping `Enabled` is node-irrelevant to `AddressSpaceChangeClassifier` (no address-space rebuild): enabled -> disabled stops the live child and publishes `BadOutOfService`; disabled -> enabled (redeploy) spawns the child and evaluation resumes on the next trigger. A script that reads a disabled VT via `ctx.GetTag` gets no pushed value for it (VirtualTag outputs are not fed through `DependencyMuxActor`, which carries driver publishes only), i.e. the same never-pushed/null upstream it would see for an enabled VT, so a dependent never sees a stale Good. `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. **Scripted alarms are unchanged:** a disabled one is still not materialised (`MaterialiseScriptedAlarms` skips it) and exposes no node; only VirtualTags keep a node. Calculation tags have no `Enabled` flag, so there is no equivalent gap. Pinned by `VirtualTagHostActorTests`, `DeploymentArtifactVirtualTagHistorizeParityTests`, `DriverHostActorVirtualTagTests`, `DraftValidatorVirtualTagTriggerTests` and, on the wire, `DisabledVirtualTagWireTests`.
- `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.Commons/OpcUa/OpcUaStatus.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/OpcUaStatus.cs
index 16fa6d7a..d8531a5c 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/OpcUaStatus.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/OpcUaStatus.cs
@@ -53,6 +53,11 @@ public static class OpcUaStatus
/// ScadaBridge, can tell a sick node from a broken script. Only produced under Scripting:Runner=OutOfProcess.
public const uint BadResourceUnavailable = 0x80040000;
+ /// The item was deliberately taken out of service. The status a disabled VirtualTag
+ /// (VirtualTag.Enabled = false) publishes, with a null value, on its still-present node
+ /// (lmxopcua#787) — distinct from , which means the tag's own script failed.
+ public const uint BadOutOfService = 0x808D0000;
+
/// Part 4 severity bucket of : the top two bits.
/// The status code to classify.
/// 0 for Good, 1 for Uncertain, 2 or 3 for Bad.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs
index 8d3b97d0..e178aacb 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs
@@ -123,20 +123,23 @@ public static class AddressSpaceChangeClassifier
}
// A VirtualTag's materialised OPC UA node (MaterialiseEquipmentVirtualTags) is derived ONLY from
- // {EquipmentId, FolderPath, Name, DataType}. Expression/DependencyRefs/Historize are engine/write-side
+ // {EquipmentId, FolderPath, Name, DataType}. Expression/DependencyRefs/Historize/Enabled are engine/write-side
// only and are adopted by VirtualTagHostActor's INDEPENDENT respawn (DriverHostActor → ApplyVirtualTags),
- // so a delta changing ONLY those three leaves a byte-identical node and needs no address-space rebuild.
+ // so a delta changing ONLY those four leaves a byte-identical node and needs no address-space rebuild.
// Whitelist-of-may-differ via `with` + the record's custom Equals: any OTHER field difference (current
// or future) makes the override unequal → falls back to a full rebuild (safe default).
/// Whether a changed equipment virtual tag differs only in fields that do not affect its materialised node.
/// The virtual-tag delta to classify.
- /// when only Expression / DependencyRefs / Historize differ; otherwise .
+ /// when only Expression / DependencyRefs / Historize / Enabled differ; otherwise .
internal static bool VtagDeltaIsNodeIrrelevant(AddressSpacePlan.EquipmentVirtualTagDelta d) =>
(d.Previous with
{
Expression = d.Current.Expression,
DependencyRefs = d.Current.DependencyRefs,
Historize = d.Current.Historize,
+ // Enabled (lmxopcua#787) is node-irrelevant too: the node exists either way; the VirtualTag host
+ // publishes BadOutOfService on disable / resumes evaluation on enable from its own apply.
+ Enabled = d.Current.Enabled,
}).Equals(d.Current);
// v3 surgical raw-tag eligibility: a NON-alarm raw tag whose only differences are the in-place-updatable
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs
index e58904a1..44fced51 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs
@@ -378,6 +378,10 @@ public sealed record EquipmentTagAlarmInfo(string AlarmType, int Severity, bool?
/// which keeps existing positional+named ctor call sites compiling and preserves byte-parity.
/// Evaluate on a dependency change (entity default true).
/// Periodic re-evaluation cadence in ms, or null for none (lmxopcua#783).
+/// False for a disabled VirtualTag (lmxopcua#787): the node is still materialised (both
+/// subtrees, so a bound client reads a status instead of BadNodeIdUnknown) but no VirtualTagActor
+/// is spawned — VirtualTagHostActor publishes BadOutOfService with a null value instead. Entity
+/// default true; the composer and the artifact decode carry it identically.
public sealed record EquipmentVirtualTagPlan(
string VirtualTagId,
string EquipmentId,
@@ -388,7 +392,8 @@ public sealed record EquipmentVirtualTagPlan(
IReadOnlyList DependencyRefs,
bool Historize = false,
bool ChangeTriggered = true,
- int? TimerIntervalMs = null)
+ int? TimerIntervalMs = null,
+ bool Enabled = true)
{
///
// Structural equality: the auto-generated record equality would compare DependencyRefs (an
@@ -406,6 +411,7 @@ public sealed record EquipmentVirtualTagPlan(
Historize == other.Historize &&
ChangeTriggered == other.ChangeTriggered &&
TimerIntervalMs == other.TimerIntervalMs &&
+ Enabled == other.Enabled &&
DependencyRefs.SequenceEqual(other.DependencyRefs, StringComparer.Ordinal);
///
@@ -414,7 +420,7 @@ public sealed record EquipmentVirtualTagPlan(
var hash = new HashCode();
hash.Add(VirtualTagId); hash.Add(EquipmentId); hash.Add(FolderPath);
hash.Add(Name); hash.Add(DataType); hash.Add(Expression);
- hash.Add(Historize); hash.Add(ChangeTriggered); hash.Add(TimerIntervalMs);
+ hash.Add(Historize); hash.Add(ChangeTriggered); hash.Add(TimerIntervalMs); hash.Add(Enabled);
foreach (var r in DependencyRefs) hash.Add(r, StringComparer.Ordinal);
return hash.ToHashCode();
}
@@ -615,10 +621,11 @@ 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.
+ // A disabled VirtualTag (lmxopcua#787, owner decision) STAYS in the plan with Enabled=false: its node
+ // is materialised as normal but it is never evaluated — the VirtualTag host publishes BadOutOfService.
+ // Scripted alarms keep their own convention (a disabled one 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 =>
@@ -636,7 +643,8 @@ public static class AddressSpaceComposer
DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded),
Historize: v.Historize,
ChangeTriggered: v.ChangeTriggered,
- TimerIntervalMs: v.TimerIntervalMs);
+ TimerIntervalMs: v.TimerIntervalMs,
+ Enabled: v.Enabled);
})
.ToList();
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 eade1c6f..c75bafb3 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
@@ -1405,9 +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;
+ // Enabled (lmxopcua#787): a disabled VirtualTag keeps its plan (node materialised, never evaluated,
+ // BadOutOfService) — byte-parity with AddressSpaceComposer.Compose's `Enabled: v.Enabled`.
+ // Absent/non-bool => the entity default (true).
+ var enabled = !(el.TryGetProperty("Enabled", out var enEl) && enEl.ValueKind == JsonValueKind.False);
var source = scriptId is not null && scriptSourceById.TryGetValue(scriptId, out var src)
? src : string.Empty;
@@ -1442,7 +1443,8 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded),
Historize: historize,
ChangeTriggered: changeTriggered,
- TimerIntervalMs: timerMs));
+ TimerIntervalMs: timerMs,
+ Enabled: enabled));
}
result.Sort((a, b) =>
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 e995b263..172b3798 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs
@@ -106,7 +106,11 @@ public sealed class VirtualTagHostActor : ReceiveActor
// and the clear fires (harmless — converges the singleton evaluator's cache to this generation).
// NOTE: this gate reduces clear FREQUENCY only; the S12 retry-once in RoslynVirtualTagEvaluator —
// not this gate — is the correctness fix for a clear racing an in-flight evaluation.
- var newExpressions = msg.Plans.Select(p => p.Expression).ToHashSet(StringComparer.Ordinal);
+ // Only ENABLED plans are evaluated (lmxopcua#787). A disabled plan keeps its node (the applier
+ // materialises every plan) but gets no child: it is published BadOutOfService below. A plan flipped
+ // enabled->disabled drops out of `desired`, so its live child is stopped by the loop right after.
+ var evaluated = msg.Plans.Where(p => p.Enabled).ToList();
+ var newExpressions = evaluated.Select(p => p.Expression).ToHashSet(StringComparer.Ordinal);
var prevExpressions = _planByVtag.Values.Select(p => p.Expression).ToHashSet(StringComparer.Ordinal);
if (!newExpressions.SetEquals(prevExpressions))
{
@@ -118,7 +122,7 @@ public sealed class VirtualTagHostActor : ReceiveActor
// an edited expression, the guard detects on its own). Cheap on a no-op redeploy (an empty map).
(_evaluator as IScriptQuarantineOwner)?.ResetQuarantines();
- var desired = new HashSet(msg.Plans.Select(p => p.VirtualTagId), StringComparer.Ordinal);
+ var desired = new HashSet(evaluated.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.
@@ -133,7 +137,7 @@ public sealed class VirtualTagHostActor : ReceiveActor
// redeploy diffs equal and is left untouched. Forgetting the child here lets the spawn-new
// loop below recreate it from the new plan — children are auto-named (no explicit name), so a
// stop+respawn cannot hit the "actor name not unique" collision.
- foreach (var p in msg.Plans)
+ foreach (var p in evaluated)
{
if (_children.ContainsKey(p.VirtualTagId)
&& _planByVtag.TryGetValue(p.VirtualTagId, out var prev)
@@ -153,7 +157,7 @@ public sealed class VirtualTagHostActor : ReceiveActor
// next evaluation overwrites — acceptable (and strictly better than the pre-H1b behaviour,
// where a renamed vtag was a silent no-op).
_nodeIdByVtag.Clear();
- foreach (var p in msg.Plans)
+ foreach (var p in evaluated)
{
_nodeIdByVtag[p.VirtualTagId] = NodeIdFor(p);
}
@@ -166,7 +170,7 @@ public sealed class VirtualTagHostActor : ReceiveActor
// Track the just-spawned vtags so the re-assert pass below skips them: a fresh child has no last
// value to re-assert and publishes its initial value on the first dependency arrival anyway.
var newlySpawned = new HashSet(StringComparer.Ordinal);
- foreach (var p in msg.Plans)
+ foreach (var p in evaluated)
{
if (_children.ContainsKey(p.VirtualTagId)) continue;
@@ -192,7 +196,7 @@ public sealed class VirtualTagHostActor : ReceiveActor
// Refresh the plan map to exactly the desired set so the next apply can detect in-place
// changes against what each live child was actually built from.
_planByVtag.Clear();
- foreach (var p in msg.Plans)
+ foreach (var p in evaluated)
{
_planByVtag[p.VirtualTagId] = p;
}
@@ -215,8 +219,20 @@ public sealed class VirtualTagHostActor : ReceiveActor
child.Tell(VirtualTagActor.ReassertValue.Instance);
}
- _log.Debug("VirtualTagHost: applied (desired={Desired}, children={Children})",
- desired.Count, _children.Count);
+ // Disabled plans: never evaluated; publish BadOutOfService + null value on the (still present) UNS
+ // node, stamped at deploy time. Re-published on EVERY apply on purpose: a deploy that rebuilds the
+ // address space resets every VirtualTag node to BadWaitingForInitialData, and this Tell lands in the
+ // publish actor's FIFO mailbox after that RebuildAddressSpace (same ordering argument as the
+ // re-assert above). Never historized (no real evaluation happened) and not in _nodeIdByVtag: no child
+ // exists to send a result for them.
+ foreach (var p in msg.Plans.Where(p => !p.Enabled))
+ {
+ _publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
+ NodeIdFor(p), null, OpcUaStatus.BadOutOfService, DateTime.UtcNow, AddressSpaceRealm.Uns));
+ }
+
+ _log.Debug("VirtualTagHost: applied (desired={Desired}, children={Children}, disabled={Disabled})",
+ desired.Count, _children.Count, msg.Plans.Count - evaluated.Count);
}
private void OnResult(VirtualTagActor.EvaluationResult result)
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DisabledVirtualTagWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DisabledVirtualTagWireTests.cs
new file mode 100644
index 00000000..0e7958dd
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DisabledVirtualTagWireTests.cs
@@ -0,0 +1,124 @@
+using System.Net;
+using System.Net.Sockets;
+using Microsoft.Extensions.Logging.Abstractions;
+using Opc.Ua;
+using Opc.Ua.Client;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
+
+///
+/// lmxopcua#787 (owner decision) — over-the-wire proof that a DISABLED VirtualTag keeps its node. Boots the real
+/// , materialises the disabled plan through the production
+/// , applies the status the
+/// VirtualTagHostActor publishes for it (BadOutOfService, null value) and asserts a real client reads
+/// BadOutOfService — NOT BadNodeIdUnknown — and that a write to it is refused.
+///
+public sealed class DisabledVirtualTagWireTests
+{
+ private const string ServerUri = "urn:OtOpcUa.DisabledVirtualTagWire";
+
+ [Fact]
+ public async Task Disabled_virtual_tag_reads_BadOutOfService_not_BadNodeIdUnknown_and_refuses_writes()
+ {
+ var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-disabledvt-{Guid.NewGuid():N}");
+ var ct = TestContext.Current.CancellationToken;
+ try
+ {
+ var (server, host, port) = await BootServerAsync(pkiRoot + "-srv", ServerUri, ct);
+ await using var _ = host;
+
+ var sink = new SdkAddressSpaceSink(server.NodeManager!);
+ var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
+ sink.EnsureFolder("eq-1", parentNodeId: null, displayName: "eq-1", realm: AddressSpaceRealm.Uns);
+ var disabled = new EquipmentVirtualTagPlan(
+ "vt-off", "eq-1", string.Empty, "Off", "Float", "return 1f;", Array.Empty(), Enabled: false);
+ var composition = new AddressSpaceComposition(
+ Array.Empty(), Array.Empty(), Array.Empty())
+ {
+ EquipmentVirtualTags = new[] { disabled },
+ };
+ applier.MaterialiseEquipmentVirtualTags(composition).ShouldBe(0);
+ // What VirtualTagHostActor.OnApply publishes for a disabled plan.
+ sink.WriteValue("eq-1/Off", null, OpcUaStatus.BadOutOfService, DateTime.UtcNow, AddressSpaceRealm.Uns);
+
+ using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
+ var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
+ ns.ShouldBeGreaterThan((ushort)0);
+ var nodeId = new NodeId("eq-1/Off", ns);
+
+ // Raw ReadAsync: ReadValueAsync throws on a Bad status, and a Bad status is exactly what we assert.
+ var readResponse = await session.ReadAsync(null, 0, TimestampsToReturn.Both,
+ new ReadValueIdCollection { new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value } }, ct);
+ var read = readResponse.Results[0];
+ read.StatusCode.Code.ShouldNotBe(StatusCodes.BadNodeIdUnknown, "the disabled VirtualTag's node must exist");
+ read.StatusCode.Code.ShouldBe(StatusCodes.BadOutOfService);
+ read.Value.ShouldBeNull();
+
+ var write = await session.WriteAsync(null, new WriteValueCollection
+ {
+ new WriteValue { NodeId = nodeId, AttributeId = Attributes.Value, Value = new DataValue(new Variant(1.0f)) },
+ }, ct);
+ StatusCode.IsBad(write.Results[0]).ShouldBeTrue("a write to a (read-only, disabled) VirtualTag must be refused");
+ }
+ finally
+ {
+ if (Directory.Exists(pkiRoot + "-srv"))
+ {
+ try { Directory.Delete(pkiRoot + "-srv", recursive: true); } catch { /* best-effort */ }
+ }
+ }
+ }
+
+ private static async Task<(OtOpcUaSdkServer Server, OpcUaApplicationHost Host, int Port)> BootServerAsync(
+ string pkiRoot, string appUri, CancellationToken ct)
+ {
+ // The port is probed per attempt (OpcUaHostStart retries on a bind race), so report the one that bound.
+ var boundPort = 0;
+ var started = await OpcUaHostStart.StartAsync(
+ port =>
+ {
+ boundPort = port;
+ return new OpcUaApplicationHost(
+ new OpcUaApplicationHostOptions
+ {
+ ApplicationName = appUri,
+ ApplicationUri = appUri,
+ OpcUaPort = port,
+ PublicHostname = "127.0.0.1",
+ PkiStoreRoot = pkiRoot,
+ EnabledSecurityProfiles = new List { OpcUaSecurityProfile.None },
+ AutoAcceptUntrustedClientCertificates = true,
+ },
+ NullLogger.Instance);
+ },
+ () => new OtOpcUaSdkServer(), ct);
+ return (started.Server, started.Host, boundPort);
+ }
+
+ private static async Task OpenSessionAsync(string endpointUrl, CancellationToken ct)
+ {
+ var appConfig = new ApplicationConfiguration
+ {
+ ApplicationName = "OtOpcUa.DisabledVtClient",
+ ApplicationUri = $"urn:OtOpcUa.DisabledVtClient.{Guid.NewGuid():N}",
+ ApplicationType = ApplicationType.Client,
+ SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()),
+ ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
+ };
+ await appConfig.ValidateAsync(ApplicationType.Client, ct);
+ appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
+
+ var endpoint = await CoreClientUtils.SelectEndpointAsync(
+ appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct);
+ var endpointConfiguration = EndpointConfiguration.Create(appConfig);
+ var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);
+
+ return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
+ appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false,
+ sessionName: "DisabledVirtualTagWireTests", sessionTimeout: 60_000,
+ identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct);
+ }
+}
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 116f50ae..2433ffbc 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,10 +163,11 @@ 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.
+ /// lmxopcua#787 — a disabled VirtualTag stays in the plan (node materialised) in BOTH the artifact
+ /// decode and the composer (byte-parity), marked Enabled=false so it is never evaluated; an absent/non-bool
+ /// Enabled keeps the default true.
[Fact]
- public void ParseComposition_drops_disabled_virtual_tag_in_parity_with_composer()
+ public void ParseComposition_keeps_disabled_virtual_tag_marked_disabled_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" };
@@ -188,8 +189,10 @@ public sealed class DeploymentArtifactVirtualTagHistorizeParityTests
});
var decoded = DeploymentArtifact.ParseComposition(blob);
- composed.EquipmentVirtualTags.ShouldHaveSingleItem().VirtualTagId.ShouldBe("vt-on");
- decoded.EquipmentVirtualTags.ShouldHaveSingleItem().VirtualTagId.ShouldBe("vt-on");
+ composed.EquipmentVirtualTags.Select(v => (v.VirtualTagId, v.Enabled))
+ .ShouldBe(new[] { ("vt-off", false), ("vt-on", true) }, ignoreOrder: false);
+ decoded.EquipmentVirtualTags.Select(v => (v.VirtualTagId, v.Enabled))
+ .ShouldBe(new[] { ("vt-off", false), ("vt-on", true) }, ignoreOrder: false);
decoded.EquipmentVirtualTags.SequenceEqual(composed.EquipmentVirtualTags).ShouldBeTrue();
}
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 ceef471c..640b7509 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
@@ -85,9 +85,10 @@ public sealed class DriverHostActorVirtualTagTests : RuntimeActorTestBase
}
/// 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.
+ /// VirtualTag host BOTH plans, the disabled one flagged Enabled=false — the host keeps its node, spawns no
+ /// evaluator for it and publishes BadOutOfService (see VirtualTagHostActorTests).
[Fact]
- public void Apply_does_not_forward_disabled_VirtualTag_to_virtual_tag_host()
+ public void Apply_forwards_disabled_VirtualTag_flagged_disabled_to_virtual_tag_host()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedDeploymentWithVirtualTag(db, RevA, withDisabledSibling: true);
@@ -104,7 +105,9 @@ public sealed class DriverHostActorVirtualTagTests : RuntimeActorTestBase
coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
var apply = vtHost.ExpectMsg(TimeSpan.FromSeconds(5));
- apply.Plans.ShouldHaveSingleItem().VirtualTagId.ShouldBe("vt-1");
+ apply.Plans.Select(p => (p.VirtualTagId, p.Enabled))
+ .OrderBy(x => x.VirtualTagId, StringComparer.Ordinal)
+ .ShouldBe(new[] { ("vt-1", true), ("vt-off", false) });
}
private static DeploymentId SeedDeploymentWithVirtualTag(
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 9b0d669b..5ace1119 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
@@ -104,6 +104,50 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
+ /// lmxopcua#787: a disabled plan is never spawned (no mux registration, so nothing to evaluate) and
+ /// its node is published BadOutOfService with a null value and a deploy-time timestamp.
+ [Fact]
+ public void Disabled_plan_is_not_spawned_and_publishes_BadOutOfService()
+ {
+ var publish = CreateTestProbe();
+ var mux = CreateTestProbe();
+ var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux.Ref, new StubEvaluator()));
+
+ var before = DateTime.UtcNow;
+ host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") with { Enabled = false } }));
+
+ var update = publish.ExpectMsg();
+ update.NodeId.ShouldBe("eq-1/speed-rpm");
+ update.Value.ShouldBeNull();
+ update.StatusCode.ShouldBe(OpcUaStatus.BadOutOfService);
+ update.Realm.ShouldBe(AddressSpaceRealm.Uns);
+ update.TimestampUtc.ShouldBeGreaterThanOrEqualTo(before);
+ // Never evaluated: an enabled child would have registered its dependency refs with the mux.
+ mux.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
+ }
+
+ /// lmxopcua#787: enabled -> disabled stops the live child (so it stops evaluating) and publishes
+ /// BadOutOfService; disabled -> enabled spawns the child again (evaluation resumes).
+ [Fact]
+ public void Toggling_enabled_stops_and_resumes_evaluation()
+ {
+ var publish = CreateTestProbe();
+ var mux = CreateTestProbe();
+ var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux.Ref, new StubEvaluator()));
+ var plan = Plan("vt-1", "eq-1", "speed-rpm");
+
+ host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { plan }));
+ mux.ExpectMsg(); // enabled: child registered
+
+ host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { plan with { Enabled = false } }));
+ mux.ExpectMsg(); // child stopped (PostStop)
+ publish.FishForMessage(
+ m => m.StatusCode == OpcUaStatus.BadOutOfService, TimeSpan.FromSeconds(5));
+
+ host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { plan }));
+ mux.ExpectMsg(); // re-enabled: evaluation resumes
+ }
+
/// Unmapped result dropped: a result for an unknown vtagId is silently ignored.
[Fact]
public void Result_for_unknown_vtag_is_dropped()