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()