fix(virtualtags): a disabled VirtualTag keeps its node and reads BadOutOfService (lmxopcua#787, owner decision)
v2-ci / unit-tests (push) Has been cancelled
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:
+124
@@ -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);
|
||||
}
|
||||
}
|
||||
+8
-5
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+6
-3
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user