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:
+1
-1
@@ -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.
|
- `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.
|
- `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.
|
- `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.
|
`ITagUpstreamSource` and `IHistoryWriter` remain available as `Core.VirtualTags` ports.
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ public static class OpcUaStatus
|
|||||||
/// ScadaBridge, can tell a sick node from a broken script. Only produced under <c>Scripting:Runner=OutOfProcess</c>.</summary>
|
/// ScadaBridge, can tell a sick node from a broken script. Only produced under <c>Scripting:Runner=OutOfProcess</c>.</summary>
|
||||||
public const uint BadResourceUnavailable = 0x80040000;
|
public const uint BadResourceUnavailable = 0x80040000;
|
||||||
|
|
||||||
|
/// <summary>The item was deliberately taken out of service. The status a disabled VirtualTag
|
||||||
|
/// (<c>VirtualTag.Enabled = false</c>) publishes, with a null value, on its still-present node
|
||||||
|
/// (lmxopcua#787) — distinct from <see cref="BadInternalError"/>, which means the tag's own script failed.</summary>
|
||||||
|
public const uint BadOutOfService = 0x808D0000;
|
||||||
|
|
||||||
/// <summary>Part 4 severity bucket of <paramref name="statusCode"/>: the top two bits.</summary>
|
/// <summary>Part 4 severity bucket of <paramref name="statusCode"/>: the top two bits.</summary>
|
||||||
/// <param name="statusCode">The status code to classify.</param>
|
/// <param name="statusCode">The status code to classify.</param>
|
||||||
/// <returns>0 for Good, 1 for Uncertain, 2 or 3 for Bad.</returns>
|
/// <returns>0 for Good, 1 for Uncertain, 2 or 3 for Bad.</returns>
|
||||||
|
|||||||
@@ -123,20 +123,23 @@ public static class AddressSpaceChangeClassifier
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A VirtualTag's materialised OPC UA node (MaterialiseEquipmentVirtualTags) is derived ONLY from
|
// 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),
|
// 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
|
// 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).
|
// or future) makes the override unequal → falls back to a full rebuild (safe default).
|
||||||
/// <summary>Whether a changed equipment virtual tag differs only in fields that do not affect its materialised node.</summary>
|
/// <summary>Whether a changed equipment virtual tag differs only in fields that do not affect its materialised node.</summary>
|
||||||
/// <param name="d">The virtual-tag delta to classify.</param>
|
/// <param name="d">The virtual-tag delta to classify.</param>
|
||||||
/// <returns><see langword="true"/> when only Expression / DependencyRefs / Historize differ; otherwise <see langword="false"/>.</returns>
|
/// <returns><see langword="true"/> when only Expression / DependencyRefs / Historize / Enabled differ; otherwise <see langword="false"/>.</returns>
|
||||||
internal static bool VtagDeltaIsNodeIrrelevant(AddressSpacePlan.EquipmentVirtualTagDelta d) =>
|
internal static bool VtagDeltaIsNodeIrrelevant(AddressSpacePlan.EquipmentVirtualTagDelta d) =>
|
||||||
(d.Previous with
|
(d.Previous with
|
||||||
{
|
{
|
||||||
Expression = d.Current.Expression,
|
Expression = d.Current.Expression,
|
||||||
DependencyRefs = d.Current.DependencyRefs,
|
DependencyRefs = d.Current.DependencyRefs,
|
||||||
Historize = d.Current.Historize,
|
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);
|
}).Equals(d.Current);
|
||||||
|
|
||||||
// v3 surgical raw-tag eligibility: a NON-alarm raw tag whose only differences are the in-place-updatable
|
// v3 surgical raw-tag eligibility: a NON-alarm raw tag whose only differences are the in-place-updatable
|
||||||
|
|||||||
@@ -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.</param>
|
/// which keeps existing positional+named ctor call sites compiling and preserves byte-parity.</param>
|
||||||
/// <param name="ChangeTriggered">Evaluate on a dependency change (entity default <c>true</c>).</param>
|
/// <param name="ChangeTriggered">Evaluate on a dependency change (entity default <c>true</c>).</param>
|
||||||
/// <param name="TimerIntervalMs">Periodic re-evaluation cadence in ms, or null for none (lmxopcua#783).</param>
|
/// <param name="TimerIntervalMs">Periodic re-evaluation cadence in ms, or null for none (lmxopcua#783).</param>
|
||||||
|
/// <param name="Enabled">False for a disabled VirtualTag (lmxopcua#787): the node is still materialised (both
|
||||||
|
/// subtrees, so a bound client reads a status instead of <c>BadNodeIdUnknown</c>) but no <c>VirtualTagActor</c>
|
||||||
|
/// is spawned — <c>VirtualTagHostActor</c> publishes <c>BadOutOfService</c> with a null value instead. Entity
|
||||||
|
/// default <c>true</c>; the composer and the artifact decode carry it identically.</param>
|
||||||
public sealed record EquipmentVirtualTagPlan(
|
public sealed record EquipmentVirtualTagPlan(
|
||||||
string VirtualTagId,
|
string VirtualTagId,
|
||||||
string EquipmentId,
|
string EquipmentId,
|
||||||
@@ -388,7 +392,8 @@ public sealed record EquipmentVirtualTagPlan(
|
|||||||
IReadOnlyList<string> DependencyRefs,
|
IReadOnlyList<string> DependencyRefs,
|
||||||
bool Historize = false,
|
bool Historize = false,
|
||||||
bool ChangeTriggered = true,
|
bool ChangeTriggered = true,
|
||||||
int? TimerIntervalMs = null)
|
int? TimerIntervalMs = null,
|
||||||
|
bool Enabled = true)
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
// Structural equality: the auto-generated record equality would compare DependencyRefs (an
|
// Structural equality: the auto-generated record equality would compare DependencyRefs (an
|
||||||
@@ -406,6 +411,7 @@ public sealed record EquipmentVirtualTagPlan(
|
|||||||
Historize == other.Historize &&
|
Historize == other.Historize &&
|
||||||
ChangeTriggered == other.ChangeTriggered &&
|
ChangeTriggered == other.ChangeTriggered &&
|
||||||
TimerIntervalMs == other.TimerIntervalMs &&
|
TimerIntervalMs == other.TimerIntervalMs &&
|
||||||
|
Enabled == other.Enabled &&
|
||||||
DependencyRefs.SequenceEqual(other.DependencyRefs, StringComparer.Ordinal);
|
DependencyRefs.SequenceEqual(other.DependencyRefs, StringComparer.Ordinal);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -414,7 +420,7 @@ public sealed record EquipmentVirtualTagPlan(
|
|||||||
var hash = new HashCode();
|
var hash = new HashCode();
|
||||||
hash.Add(VirtualTagId); hash.Add(EquipmentId); hash.Add(FolderPath);
|
hash.Add(VirtualTagId); hash.Add(EquipmentId); hash.Add(FolderPath);
|
||||||
hash.Add(Name); hash.Add(DataType); hash.Add(Expression);
|
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);
|
foreach (var r in DependencyRefs) hash.Add(r, StringComparer.Ordinal);
|
||||||
return hash.ToHashCode();
|
return hash.ToHashCode();
|
||||||
}
|
}
|
||||||
@@ -615,10 +621,11 @@ public static class AddressSpaceComposer
|
|||||||
// DependencyRefs = the distinct ctx.GetTag("…") literals the VirtualTagActor subscribes to.
|
// DependencyRefs = the distinct ctx.GetTag("…") literals the VirtualTagActor subscribes to.
|
||||||
// VirtualTag has no FolderPath today → "".
|
// VirtualTag has no FolderPath today → "".
|
||||||
var scriptsById = resolvedScripts.ToDictionary(s => s.ScriptId, StringComparer.Ordinal);
|
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 —
|
// A disabled VirtualTag (lmxopcua#787, owner decision) STAYS in the plan with Enabled=false: its node
|
||||||
// the same as a disabled scripted alarm (which is not materialised). Mirrored by the artifact decode.
|
// 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
|
var equipmentVirtualTags = vtags
|
||||||
.Where(v => v.Enabled)
|
|
||||||
.OrderBy(v => v.EquipmentId, StringComparer.Ordinal)
|
.OrderBy(v => v.EquipmentId, StringComparer.Ordinal)
|
||||||
.ThenBy(v => v.Name, StringComparer.Ordinal)
|
.ThenBy(v => v.Name, StringComparer.Ordinal)
|
||||||
.Select(v =>
|
.Select(v =>
|
||||||
@@ -636,7 +643,8 @@ public static class AddressSpaceComposer
|
|||||||
DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded),
|
DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded),
|
||||||
Historize: v.Historize,
|
Historize: v.Historize,
|
||||||
ChangeTriggered: v.ChangeTriggered,
|
ChangeTriggered: v.ChangeTriggered,
|
||||||
TimerIntervalMs: v.TimerIntervalMs);
|
TimerIntervalMs: v.TimerIntervalMs,
|
||||||
|
Enabled: v.Enabled);
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
|||||||
@@ -1405,9 +1405,10 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
|
|||||||
if (string.IsNullOrWhiteSpace(virtualTagId) || string.IsNullOrWhiteSpace(equipmentId)
|
if (string.IsNullOrWhiteSpace(virtualTagId) || string.IsNullOrWhiteSpace(equipmentId)
|
||||||
|| string.IsNullOrWhiteSpace(name)) continue;
|
|| string.IsNullOrWhiteSpace(name)) continue;
|
||||||
|
|
||||||
// Enabled (lmxopcua#787): a disabled VirtualTag is not deployed or evaluated — byte-parity with
|
// Enabled (lmxopcua#787): a disabled VirtualTag keeps its plan (node materialised, never evaluated,
|
||||||
// AddressSpaceComposer.Compose's `.Where(v => v.Enabled)`. Absent/non-bool => the entity default (true).
|
// BadOutOfService) — byte-parity with AddressSpaceComposer.Compose's `Enabled: v.Enabled`.
|
||||||
if (el.TryGetProperty("Enabled", out var enEl) && enEl.ValueKind == JsonValueKind.False) continue;
|
// 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)
|
var source = scriptId is not null && scriptSourceById.TryGetValue(scriptId, out var src)
|
||||||
? src : string.Empty;
|
? src : string.Empty;
|
||||||
@@ -1442,7 +1443,8 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
|
|||||||
DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded),
|
DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded),
|
||||||
Historize: historize,
|
Historize: historize,
|
||||||
ChangeTriggered: changeTriggered,
|
ChangeTriggered: changeTriggered,
|
||||||
TimerIntervalMs: timerMs));
|
TimerIntervalMs: timerMs,
|
||||||
|
Enabled: enabled));
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Sort((a, b) =>
|
result.Sort((a, b) =>
|
||||||
|
|||||||
@@ -106,7 +106,11 @@ public sealed class VirtualTagHostActor : ReceiveActor
|
|||||||
// and the clear fires (harmless — converges the singleton evaluator's cache to this generation).
|
// 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 —
|
// 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.
|
// 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);
|
var prevExpressions = _planByVtag.Values.Select(p => p.Expression).ToHashSet(StringComparer.Ordinal);
|
||||||
if (!newExpressions.SetEquals(prevExpressions))
|
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).
|
// an edited expression, the guard detects on its own). Cheap on a no-op redeploy (an empty map).
|
||||||
(_evaluator as IScriptQuarantineOwner)?.ResetQuarantines();
|
(_evaluator as IScriptQuarantineOwner)?.ResetQuarantines();
|
||||||
|
|
||||||
var desired = new HashSet<string>(msg.Plans.Select(p => p.VirtualTagId), StringComparer.Ordinal);
|
var desired = new HashSet<string>(evaluated.Select(p => p.VirtualTagId), StringComparer.Ordinal);
|
||||||
|
|
||||||
// Stop + forget children whose vtagId is no longer desired. Stopping the child triggers its
|
// Stop + forget children whose vtagId is no longer desired. Stopping the child triggers its
|
||||||
// PostStop, which unregisters its interest from the mux.
|
// 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
|
// 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
|
// 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.
|
// 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)
|
if (_children.ContainsKey(p.VirtualTagId)
|
||||||
&& _planByVtag.TryGetValue(p.VirtualTagId, out var prev)
|
&& _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,
|
// next evaluation overwrites — acceptable (and strictly better than the pre-H1b behaviour,
|
||||||
// where a renamed vtag was a silent no-op).
|
// where a renamed vtag was a silent no-op).
|
||||||
_nodeIdByVtag.Clear();
|
_nodeIdByVtag.Clear();
|
||||||
foreach (var p in msg.Plans)
|
foreach (var p in evaluated)
|
||||||
{
|
{
|
||||||
_nodeIdByVtag[p.VirtualTagId] = NodeIdFor(p);
|
_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
|
// 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.
|
// value to re-assert and publishes its initial value on the first dependency arrival anyway.
|
||||||
var newlySpawned = new HashSet<string>(StringComparer.Ordinal);
|
var newlySpawned = new HashSet<string>(StringComparer.Ordinal);
|
||||||
foreach (var p in msg.Plans)
|
foreach (var p in evaluated)
|
||||||
{
|
{
|
||||||
if (_children.ContainsKey(p.VirtualTagId)) continue;
|
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
|
// 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.
|
// changes against what each live child was actually built from.
|
||||||
_planByVtag.Clear();
|
_planByVtag.Clear();
|
||||||
foreach (var p in msg.Plans)
|
foreach (var p in evaluated)
|
||||||
{
|
{
|
||||||
_planByVtag[p.VirtualTagId] = p;
|
_planByVtag[p.VirtualTagId] = p;
|
||||||
}
|
}
|
||||||
@@ -215,8 +219,20 @@ public sealed class VirtualTagHostActor : ReceiveActor
|
|||||||
child.Tell(VirtualTagActor.ReassertValue.Instance);
|
child.Tell(VirtualTagActor.ReassertValue.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.Debug("VirtualTagHost: applied (desired={Desired}, children={Children})",
|
// Disabled plans: never evaluated; publish BadOutOfService + null value on the (still present) UNS
|
||||||
desired.Count, _children.Count);
|
// 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)
|
private void OnResult(VirtualTagActor.EvaluationResult result)
|
||||||
|
|||||||
+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();
|
d.ChangeTriggered.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>lmxopcua#787 — a disabled VirtualTag is dropped by both the artifact decode and the composer
|
/// <summary>lmxopcua#787 — a disabled VirtualTag stays in the plan (node materialised) in BOTH the artifact
|
||||||
/// (byte-parity), so it is neither deployed nor evaluated; absent/non-bool Enabled keeps the default true.</summary>
|
/// 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]
|
[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 area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
|
||||||
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
|
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);
|
var decoded = DeploymentArtifact.ParseComposition(blob);
|
||||||
|
|
||||||
composed.EquipmentVirtualTags.ShouldHaveSingleItem().VirtualTagId.ShouldBe("vt-on");
|
composed.EquipmentVirtualTags.Select(v => (v.VirtualTagId, v.Enabled))
|
||||||
decoded.EquipmentVirtualTags.ShouldHaveSingleItem().VirtualTagId.ShouldBe("vt-on");
|
.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();
|
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
|
/// <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]
|
[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 db = NewInMemoryDbFactory();
|
||||||
var deploymentId = SeedDeploymentWithVirtualTag(db, RevA, withDisabledSibling: true);
|
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);
|
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||||||
var apply = vtHost.ExpectMsg<VirtualTagHostActor.ApplyVirtualTags>(TimeSpan.FromSeconds(5));
|
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(
|
private static DeploymentId SeedDeploymentWithVirtualTag(
|
||||||
|
|||||||
@@ -104,6 +104,50 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
|
|||||||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
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>
|
/// <summary>Unmapped result dropped: a result for an unknown vtagId is silently ignored.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Result_for_unknown_vtag_is_dropped()
|
public void Result_for_unknown_vtag_is_dropped()
|
||||||
|
|||||||
Reference in New Issue
Block a user