fix(virtualtags): a disabled VirtualTag keeps its node and reads BadOutOfService (lmxopcua#787, owner decision)
v2-ci / unit-tests (push) Has been cancelled

Revises 963b0d66, which dropped a disabled VirtualTag from the runtime plan so a
bound client got BadNodeIdUnknown. Now the plan carries Enabled (part of value
equality): AddressSpaceComposer.Compose and DeploymentArtifact.BuildEquipmentVirtualTagPlans
keep the row with Enabled=false (byte-parity), the applier materialises the node as
normal, and VirtualTagHostActor spawns no VirtualTagActor for it but publishes
BadOutOfService (new OpcUaStatus constant) with a null value stamped at deploy, on
every apply. Enabled flips are node-irrelevant to the change classifier; enabled->disabled
stops the child, disabled->enabled resumes evaluation. Validator exemption and name
uniqueness unchanged. Scripted alarms keep their not-materialised convention.
Tests updated; new wire test DisabledVirtualTagWireTests. docs/VirtualTags.md updated.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-10 04:48:56 -05:00
parent 8582ce91bc
commit f201f16fd4
10 changed files with 238 additions and 30 deletions
@@ -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;
/// <summary>
/// lmxopcua#787 (owner decision) — over-the-wire proof that a DISABLED VirtualTag keeps its node. Boots the real
/// <see cref="OtOpcUaSdkServer"/>, materialises the disabled plan through the production
/// <see cref="AddressSpaceApplier.MaterialiseEquipmentVirtualTags"/>, applies the status the
/// <c>VirtualTagHostActor</c> publishes for it (<c>BadOutOfService</c>, null value) and asserts a real client reads
/// <c>BadOutOfService</c> — NOT <c>BadNodeIdUnknown</c> — and that a write to it is refused.
/// </summary>
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<AddressSpaceApplier>.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<string>(), Enabled: false);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
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> { OpcUaSecurityProfile.None },
AutoAcceptUntrustedClientCertificates = true,
},
NullLogger<OpcUaApplicationHost>.Instance);
},
() => new OtOpcUaSdkServer(), ct);
return (started.Server, started.Host, boundPort);
}
private static async Task<ISession> 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);
}
}
@@ -163,10 +163,11 @@ public sealed class DeploymentArtifactVirtualTagHistorizeParityTests
d.ChangeTriggered.ShouldBeTrue();
}
/// <summary>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.</summary>
/// <summary>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.</summary>
[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();
}
@@ -85,9 +85,10 @@ public sealed class DriverHostActorVirtualTagTests : RuntimeActorTestBase
}
/// <summary>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.</summary>
/// 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).</summary>
[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<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
var apply = vtHost.ExpectMsg<VirtualTagHostActor.ApplyVirtualTags>(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(
@@ -104,6 +104,50 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>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.</summary>
[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<OpcUaPublishActor.AttributeValueUpdate>();
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));
}
/// <summary>lmxopcua#787: enabled -> disabled stops the live child (so it stops evaluating) and publishes
/// BadOutOfService; disabled -> enabled spawns the child again (evaluation resumes).</summary>
[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<DependencyMuxActor.RegisterInterest>(); // enabled: child registered
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { plan with { Enabled = false } }));
mux.ExpectMsg<DependencyMuxActor.UnregisterInterest>(); // child stopped (PostStop)
publish.FishForMessage<OpcUaPublishActor.AttributeValueUpdate>(
m => m.StatusCode == OpcUaStatus.BadOutOfService, TimeSpan.FromSeconds(5));
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { plan }));
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(); // re-enabled: evaluation resumes
}
/// <summary>Unmapped result dropped: a result for an unknown vtagId is silently ignored.</summary>
[Fact]
public void Result_for_unknown_vtag_is_dropped()