#519 — a driver's OPC UA status code now reaches a client with its sub-code intact. DriverInstanceActor projected the driver's uint onto a 3-state Good/Uncertain/Bad enum and OtOpcUaNodeManager re-expanded Bad to a bare 0x80000000, so BadNoCommunication, BadTypeMismatch and BadNotSupported were indistinguishable on the wire and every driver's status constants (incl. the 16 #497 had just corrected) were diagnostic in logs only. Resolved by option (b): the raw uint rides AttributeValuePublished -> AttributeValueUpdate -> IOpcUaAddressSpaceSink.WriteValue, OpcUaQuality is deleted, and severity classification is a helper (OpcUaStatus.IsGood/IsUncertain/IsBad). The surface was wider than the issue costed (41 files, not "the sink and 3 implementations"): OpcUaQuality had non-driver producers with no uint to carry. Each got its natural code — BadInternalError for a failed virtual-tag script, BadNoCommunication for driver comms loss — and ScriptedAlarmHostActor's pair of mutually-inverse uint<->enum mappers is deleted, having round-tripped a code the engine already held. AddressSpaceApplier's removal writes deliberately keep the generic Bad: not losing a code the driver had is this issue; inventing one for the deploy path is a separate call. Behaviour change: WriteAlarmQuality fires its Part 9 event on a status-code change rather than a severity-bucket change. While the annotation was 3-state those were the same predicate; now a move between two Bad reasons changes what a subscriber reads. New SDK-free named constants live on Commons.OpcUa.OpcUaStatus, and StatusCodeParityTests is widened to cover them — that guard is reflection-driven, so it can only see a named constant, never an inline literal. #517 — the symptom is real but the issue named the wrong path and its fix was unsafe. ShutdownAsync cannot race: DriverInstanceActor calls it from PostStop, after the mailbox drains. (Core/Hosting/DriverHost, the one class with a concurrent GetHealth, has no production call site.) InitializeAsync can: it runs on a fire-and-forget Task.Run, and MTConnectDriver writes Healthy then starts loops that can throw into a catch which disposes the client before writing Faulted. Moving the write earlier, as suggested, inverts the terminal state — teardown drains in-flight loops that write health as they unwind, so a late loop could leave Healthy at rest with every connection closed. The fix is a bracket (conservative before, terminal after), applied to MTConnect's init-failure path only; shutdown keeps its single trailing write, and the rule plus the reachability table are documented on IDriver.ShutdownAsync. CalculationDriver.GetHealth returned a hard-coded Healthy that consulted nothing — Healthy before initialize, after shutdown and after dispose. Now derived from _running. Its test, named Health_is_always_Connected, asserted the defect and is replaced. Every new guard was falsified by breaking the production code and watching it go red. The two sub-code tests each assert two different Bad codes, since a single-code assertion could pass against a hard-coded constant under the old projection. Pre-existing failures, verified unchanged on a stashed tree and not chased: 4 in Driver.AbLegacy.IntegrationTests (slc500/micrologix/plc5 fixtures not up) and 3 in Host.IntegrationTests. Closes #517 Closes #519 Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
@@ -279,7 +279,7 @@ public sealed class AddressSpaceApplier
|
||||
else
|
||||
{
|
||||
// Plain value variable → terminal Bad so an in-flight MonitoredItem sees the removal.
|
||||
SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Uns);
|
||||
SafeWriteValue(nodeId, null, OpcUaStatus.Bad, ts, AddressSpaceRealm.Uns);
|
||||
if (!SafeRemoveVariable(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
|
||||
}
|
||||
}
|
||||
@@ -288,7 +288,7 @@ public sealed class AddressSpaceApplier
|
||||
foreach (var v in plan.RemovedEquipmentVirtualTags.Where(t => NotSubsumed(t.EquipmentId)))
|
||||
{
|
||||
var nodeId = UnsEquipmentVar(v.EquipmentId, v.FolderPath, v.Name);
|
||||
SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Uns);
|
||||
SafeWriteValue(nodeId, null, OpcUaStatus.Bad, ts, AddressSpaceRealm.Uns);
|
||||
if (!SafeRemoveVariable(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
|
||||
}
|
||||
|
||||
@@ -311,7 +311,7 @@ public sealed class AddressSpaceApplier
|
||||
// ref only reaches here on a genuine dereference (the equipment dropped the reference).
|
||||
foreach (var v in plan.RemovedUnsReferenceVariables)
|
||||
{
|
||||
SafeWriteValue(v.NodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Uns);
|
||||
SafeWriteValue(v.NodeId, null, OpcUaStatus.Bad, ts, AddressSpaceRealm.Uns);
|
||||
if (!SafeRemoveVariable(surgical, v.NodeId, AddressSpaceRealm.Uns)) return false;
|
||||
}
|
||||
|
||||
@@ -327,7 +327,7 @@ public sealed class AddressSpaceApplier
|
||||
}
|
||||
else
|
||||
{
|
||||
SafeWriteValue(t.NodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Raw);
|
||||
SafeWriteValue(t.NodeId, null, OpcUaStatus.Bad, ts, AddressSpaceRealm.Raw);
|
||||
if (!SafeRemoveVariable(surgical, t.NodeId, AddressSpaceRealm.Raw)) return false;
|
||||
}
|
||||
}
|
||||
@@ -365,11 +365,16 @@ public sealed class AddressSpaceApplier
|
||||
}
|
||||
|
||||
/// <summary>Write a value, swallowing (and Warning-logging) any sink fault — used for the terminal Bad
|
||||
/// on a removed variable so an in-flight MonitoredItem observes the removal.</summary>
|
||||
/// on a removed variable so an in-flight MonitoredItem observes the removal.
|
||||
/// <para>Every caller passes the generic <see cref="OpcUaStatus.Bad"/>, which is exactly what the
|
||||
/// retired 3-state projection produced here. A removal could say something more specific (the node is
|
||||
/// about to stop existing, so <c>BadNodeIdUnknown</c> would be truer), but #519 was about not LOSING a
|
||||
/// sub-code the driver already had — inventing a new one for the deploy path is a separate call, so
|
||||
/// this deliberately stays behaviour-identical.</para></summary>
|
||||
/// <returns><c>true</c> when the write landed; <c>false</c> when the sink threw.</returns>
|
||||
private bool SafeWriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts, AddressSpaceRealm realm)
|
||||
private bool SafeWriteValue(string nodeId, object? value, uint statusCode, DateTime ts, AddressSpaceRealm realm)
|
||||
{
|
||||
try { _sink.WriteValue(nodeId, value, quality, ts, realm); return true; }
|
||||
try { _sink.WriteValue(nodeId, value, statusCode, ts, realm); return true; }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WriteValue threw for {Node}", nodeId); return false; }
|
||||
}
|
||||
|
||||
|
||||
@@ -408,7 +408,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// <param name="value">The new value to write.</param>
|
||||
/// <param name="quality">The OPC UA quality status code.</param>
|
||||
/// <param name="sourceTimestampUtc">The timestamp of the value in UTC.</param>
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(nodeId);
|
||||
EnsureAddressSpaceCreated();
|
||||
@@ -426,7 +426,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
}
|
||||
|
||||
variable.Value = value;
|
||||
variable.StatusCode = StatusFromQuality(quality);
|
||||
// #519 — the caller's status code reaches the SDK verbatim, sub-code included. This used to
|
||||
// re-expand a 3-state enum, so every Bad became a bare StatusCodes.Bad (0x80000000) and a
|
||||
// driver's BadNoCommunication was indistinguishable from BadTypeMismatch at the client.
|
||||
variable.StatusCode = new StatusCode(statusCode);
|
||||
variable.Timestamp = sourceTimestampUtc;
|
||||
variable.ClearChangeMasks(SystemContext, includeChildren: false);
|
||||
}
|
||||
@@ -503,7 +506,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// null-guard it like Confirmed/Shelving in case a leaner SDK child set omits it.
|
||||
if (condition.Quality is not null)
|
||||
{
|
||||
condition.Quality.Value = StatusFromQuality(state.Quality);
|
||||
condition.Quality.Value = new StatusCode(state.StatusCode);
|
||||
if (condition.Quality.SourceTimestamp is not null) condition.Quality.SourceTimestamp.Value = sourceTimestampUtc;
|
||||
}
|
||||
|
||||
@@ -547,19 +550,23 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
|
||||
/// <summary>
|
||||
/// #477 Layer 2 — apply a source-data quality annotation to a materialised condition, OUT OF BAND
|
||||
/// from any alarm transition. Used by the driver-connectivity path (comms lost → Bad, restored →
|
||||
/// Good) so a native condition whose device is unreachable stops reporting the accidentally-Good
|
||||
/// default. This sets ONLY <see cref="ConditionState.Quality"/> — it never touches
|
||||
/// from any alarm transition. Used by the driver-connectivity path (comms lost → BadNoCommunication,
|
||||
/// restored → Good) so a native condition whose device is unreachable stops reporting the
|
||||
/// accidentally-Good default. This sets ONLY <see cref="ConditionState.Quality"/> — it never touches
|
||||
/// Active / Acked / Severity / Retain (a comms-lost active alarm must stay active). A change in the
|
||||
/// quality bucket is a genuine Part 9 condition change, so it fires one condition event carrying the
|
||||
/// new Quality; an unchanged bucket suppresses (no spurious event). Unknown / unmaterialised node ⇒
|
||||
/// status code is a genuine Part 9 condition change, so it fires one condition event carrying the
|
||||
/// new Quality; an unchanged code suppresses (no spurious event). Unknown / unmaterialised node ⇒
|
||||
/// safe no-op (a mid-rebuild race must not fault a connectivity update), mirroring the other writes.
|
||||
/// <para><b>#519 changed the fire test from bucket to full code.</b> While the annotation was a
|
||||
/// 3-state enum, "the bucket moved" and "the value changed" were the same predicate. Now that the
|
||||
/// sub-code survives, a move from one Bad reason to another Bad reason genuinely changes what a
|
||||
/// client reads, so it fires — suppressing it would leave subscribers holding a stale reason.</para>
|
||||
/// </summary>
|
||||
/// <param name="alarmNodeId">The condition node id (RawPath for a native alarm).</param>
|
||||
/// <param name="quality">The source-data quality to annotate.</param>
|
||||
/// <param name="statusCode">The source-data status code to annotate, sub-code included.</param>
|
||||
/// <param name="sourceTimestampUtc">Timestamp of the connectivity transition in UTC.</param>
|
||||
/// <param name="realm">The condition's address-space realm.</param>
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
|
||||
EnsureAddressSpaceCreated();
|
||||
@@ -573,13 +580,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
if (!_alarmConditions.TryGetValue(key, out var condition) || condition.Quality is null)
|
||||
return;
|
||||
|
||||
var newCode = StatusFromQuality(quality);
|
||||
var newCode = new StatusCode(statusCode);
|
||||
var changed = condition.Quality.Value.Code != newCode.Code;
|
||||
|
||||
condition.Quality.Value = newCode;
|
||||
if (condition.Quality.SourceTimestamp is not null) condition.Quality.SourceTimestamp.Value = sourceTimestampUtc;
|
||||
|
||||
// Fire ONLY on a real bucket change so a steady-state connectivity re-assert doesn't spam events.
|
||||
// Fire ONLY on a real change so a steady-state connectivity re-assert doesn't spam events.
|
||||
if (changed)
|
||||
{
|
||||
condition.Time.Value = sourceTimestampUtc;
|
||||
@@ -758,7 +765,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
Message: state.Message ?? string.Empty,
|
||||
// If the node has no Quality child, WriteAlarmCondition's projection is a no-op there; fold to the
|
||||
// node's read-back default (Good) so a snapshot Quality can't register a spurious delta.
|
||||
Quality: condition.Quality is not null ? StatusFromQuality(state.Quality) : StatusCodes.Good);
|
||||
Quality: condition.Quality is not null ? new StatusCode(state.StatusCode) : StatusCodes.Good);
|
||||
|
||||
/// <summary>Map the live shelving state machine's CurrentState back to our 3-way
|
||||
/// <see cref="AlarmShelvingKind"/> by matching its well-known Part 9 state object id. Any node without
|
||||
@@ -3315,10 +3322,4 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
return v;
|
||||
}
|
||||
|
||||
private static StatusCode StatusFromQuality(OpcUaQuality quality) => quality switch
|
||||
{
|
||||
OpcUaQuality.Good => StatusCodes.Good,
|
||||
OpcUaQuality.Uncertain => StatusCodes.Uncertain,
|
||||
_ => StatusCodes.Bad,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,15 +21,15 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
=> _nodeManager.WriteValue(nodeId, value, quality, sourceTimestampUtc, realm);
|
||||
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
=> _nodeManager.WriteValue(nodeId, value, statusCode, sourceTimestampUtc, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
=> _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
|
||||
|
||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
=> _nodeManager.WriteAlarmQuality(alarmNodeId, quality, sourceTimestampUtc, realm);
|
||||
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
|
||||
=> _nodeManager.WriteAlarmQuality(alarmNodeId, statusCode, sourceTimestampUtc, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
|
||||
|
||||
@@ -939,10 +939,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
if (_nodeIdByDriverRef.TryGetValue((msg.DriverInstanceId, msg.FullReference), out var nodeIds))
|
||||
{
|
||||
// v3 Batch 4 single-source fan-out: one driver publish for a RawPath lands on the raw NodeId AND
|
||||
// every referencing UNS NodeId, each with its realm, carrying IDENTICAL value/quality/timestamp.
|
||||
// every referencing UNS NodeId, each with its realm, carrying IDENTICAL value/status/timestamp.
|
||||
foreach (var n in nodeIds)
|
||||
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AttributeValueUpdate(
|
||||
n.NodeId, msg.Value, msg.Quality, msg.TimestampUtc, n.Realm));
|
||||
n.NodeId, msg.Value, msg.StatusCode, msg.TimestampUtc, n.Realm));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -975,8 +975,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// #477 — a child driver's connectivity transition. Annotates the source-data Quality of EVERY native
|
||||
/// alarm condition the driver owns: comms lost → <see cref="OpcUaQuality.Bad"/>, restored →
|
||||
/// <see cref="OpcUaQuality.Good"/>. This is the ONLY signal for a comms-lost native source, because a
|
||||
/// alarm condition the driver owns: comms lost → <see cref="OpcUaStatus.BadNoCommunication"/>, restored →
|
||||
/// <see cref="OpcUaStatus.Good"/>. This is the ONLY signal for a comms-lost native source, because a
|
||||
/// disconnected driver emits no alarm transitions — the alarm feed goes silent, so without this a
|
||||
/// comms-lost condition would keep reporting the accidentally-Good default forever.
|
||||
/// <para>
|
||||
@@ -992,7 +992,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
if (_opcUaPublishActor is null) return;
|
||||
|
||||
var quality = msg.Connected ? OpcUaQuality.Good : OpcUaQuality.Bad;
|
||||
// #519: name the actual cause. This is a connectivity transition, so the Bad case is
|
||||
// BadNoCommunication — the bare OpcUaQuality.Bad it used to send reached clients as 0x80000000,
|
||||
// indistinguishable from any other fault.
|
||||
var statusCode = msg.Connected ? OpcUaStatus.Good : OpcUaStatus.BadNoCommunication;
|
||||
var ts = DateTime.UtcNow;
|
||||
var annotated = 0;
|
||||
// Fan out to every condition this driver owns. _alarmNodeIdByDriverRef is keyed by
|
||||
@@ -1003,14 +1006,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
foreach (var n in nodeIds)
|
||||
{
|
||||
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmQualityUpdate(
|
||||
n.NodeId, quality, ts, n.Realm));
|
||||
n.NodeId, statusCode, ts, n.Realm));
|
||||
annotated++;
|
||||
}
|
||||
}
|
||||
|
||||
if (annotated > 0)
|
||||
_log.Debug("DriverHost {Node}: driver {Driver} {State} — annotated {Count} native condition(s) {Quality}",
|
||||
_localNode, msg.DriverInstanceId, msg.Connected ? "connected" : "disconnected", annotated, quality);
|
||||
_log.Debug("DriverHost {Node}: driver {Driver} {State} — annotated {Count} native condition(s) 0x{Status:X8}",
|
||||
_localNode, msg.DriverInstanceId, msg.Connected ? "connected" : "disconnected", annotated, statusCode);
|
||||
}
|
||||
|
||||
private void ForwardNativeAlarm(DriverInstanceActor.AttributeAlarmPublished msg)
|
||||
|
||||
@@ -102,8 +102,11 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
/// </summary>
|
||||
public sealed record ForceReconnect;
|
||||
/// <summary>Published to the actor's parent whenever the subscribed IDriver fires
|
||||
/// <see cref="ISubscribable.OnDataChange"/>. The parent forwards to OpcUaPublishActor.</summary>
|
||||
public sealed record AttributeValuePublished(string DriverInstanceId, string FullReference, object? Value, OpcUaQuality Quality, DateTime TimestampUtc);
|
||||
/// <see cref="ISubscribable.OnDataChange"/>. The parent forwards to OpcUaPublishActor.
|
||||
/// <para><paramref name="StatusCode"/> is the driver's full OPC UA status code including its sub-code
|
||||
/// (#519) — not a severity bucket. Classify it with <see cref="OpcUaStatus.IsGood"/> and friends rather
|
||||
/// than comparing it to <see cref="OpcUaStatus.Good"/>.</para></summary>
|
||||
public sealed record AttributeValuePublished(string DriverInstanceId, string FullReference, object? Value, uint StatusCode, DateTime TimestampUtc);
|
||||
private sealed record DataChangeForward(string FullReference, DataValueSnapshot Snapshot);
|
||||
/// <summary>Published to the parent whenever the subscribed driver (an <see cref="IAlarmSource"/>) fires
|
||||
/// <see cref="IAlarmSource.OnAlarmEvent"/>. The parent (<see cref="DriverHostActor"/>) projects + routes it
|
||||
@@ -957,27 +960,19 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Forward a driver's data change to the parent for OPC UA publication.
|
||||
/// <para>The driver's status code rides through UNCHANGED (#519). This used to project it onto a
|
||||
/// 3-state Good/Uncertain/Bad enum here, which the node manager then re-expanded to a bare
|
||||
/// <c>0x80000000</c> — so no driver's sub-code ever reached a client, and the status constants each
|
||||
/// driver maintains were diagnostic in logs only.</para></summary>
|
||||
private void OnDataChangeForward(DataChangeForward msg)
|
||||
{
|
||||
var quality = QualityFromStatus(msg.Snapshot.StatusCode);
|
||||
var ts = msg.Snapshot.SourceTimestampUtc ?? msg.Snapshot.ServerTimestampUtc;
|
||||
Context.Parent.Tell(new AttributeValuePublished(_driverInstanceId, msg.FullReference, msg.Snapshot.Value, quality, ts));
|
||||
Context.Parent.Tell(new AttributeValuePublished(
|
||||
_driverInstanceId, msg.FullReference, msg.Snapshot.Value, msg.Snapshot.StatusCode, ts));
|
||||
}
|
||||
|
||||
/// <summary>Translate an OPC UA status code to the 3-state <see cref="OpcUaQuality"/> projection
|
||||
/// the publish actor consumes. Severity bits (top 2): 00 = Good, 01 = Uncertain, 10/11 = Bad.</summary>
|
||||
private static OpcUaQuality QualityFromStatus(uint statusCode)
|
||||
{
|
||||
var severity = statusCode >> 30;
|
||||
return severity switch
|
||||
{
|
||||
0 => OpcUaQuality.Good,
|
||||
1 => OpcUaQuality.Uncertain,
|
||||
_ => OpcUaQuality.Bad,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsGoodStatus(uint statusCode) => (statusCode >> 30) == 0;
|
||||
private static bool IsGoodStatus(uint statusCode) => OpcUaStatus.IsGood(statusCode);
|
||||
|
||||
/// <summary>Resolve the resilience-pipeline host key for a single driver-side reference. Multi-host
|
||||
/// drivers (e.g. Modbus across N PLCs) implement <see cref="IPerCallHostResolver"/> so one dead host
|
||||
|
||||
@@ -42,10 +42,12 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
/// <summary>A driver/vtag value update for a single materialised Variable node. v3 Batch 4: carries the
|
||||
/// node's <see cref="AddressSpaceRealm"/> so the sink resolves the right namespace — the driver fan-out
|
||||
/// posts one of these per registered NodeId (the raw node in <see cref="AddressSpaceRealm.Raw"/> and each
|
||||
/// referencing UNS node in <see cref="AddressSpaceRealm.Uns"/>) with identical value/quality/timestamp.
|
||||
/// referencing UNS node in <see cref="AddressSpaceRealm.Uns"/>) with identical value/status/timestamp.
|
||||
/// <see cref="Realm"/> defaults to <see cref="AddressSpaceRealm.Uns"/> so pre-v3 callers (VirtualTag
|
||||
/// equipment nodes) keep compiling; the driver fan-out passes it EXPLICITLY per node.</summary>
|
||||
public sealed record AttributeValueUpdate(string NodeId, object? Value, OpcUaQuality Quality, DateTime TimestampUtc, AddressSpaceRealm Realm);
|
||||
/// equipment nodes) keep compiling; the driver fan-out passes it EXPLICITLY per node.
|
||||
/// <para><see cref="StatusCode"/> is the full OPC UA status code, sub-code included (#519); the sink
|
||||
/// hands it to the SDK verbatim.</para></summary>
|
||||
public sealed record AttributeValueUpdate(string NodeId, object? Value, uint StatusCode, DateTime TimestampUtc, AddressSpaceRealm Realm);
|
||||
|
||||
/// <summary>Carries the full Part 9 condition state for a scripted alarm to the sink. The
|
||||
/// <paramref name="State"/> snapshot is the Commons projection the Runtime host maps from the engine's
|
||||
@@ -59,14 +61,14 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
/// scripted alarms (default), <see cref="AddressSpaceRealm.Raw"/> for v3 native raw conditions.</param>
|
||||
public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc, AddressSpaceRealm Realm);
|
||||
/// <summary>#477 — annotate a materialised condition's source-data Quality out of band from any alarm
|
||||
/// transition (the driver-connectivity path: comms lost → <see cref="OpcUaQuality.Bad"/>, restored →
|
||||
/// <see cref="OpcUaQuality.Good"/>). Routed to <see cref="IOpcUaAddressSpaceSink.WriteAlarmQuality"/>,
|
||||
/// which sets ONLY Quality and fires one Part 9 event on a quality-bucket change.</summary>
|
||||
/// transition (the driver-connectivity path: comms lost → <see cref="OpcUaStatus.BadNoCommunication"/>,
|
||||
/// restored → <see cref="OpcUaStatus.Good"/>). Routed to <see cref="IOpcUaAddressSpaceSink.WriteAlarmQuality"/>,
|
||||
/// which sets ONLY Quality and fires one Part 9 event on a status-code change.</summary>
|
||||
/// <param name="AlarmNodeId">The condition node id (RawPath for a native alarm).</param>
|
||||
/// <param name="Quality">The source-data quality to annotate.</param>
|
||||
/// <param name="StatusCode">The source-data status code to annotate, sub-code included.</param>
|
||||
/// <param name="TimestampUtc">The connectivity transition timestamp in UTC.</param>
|
||||
/// <param name="Realm">The namespace realm the condition lives in (<see cref="AddressSpaceRealm.Raw"/> for native).</param>
|
||||
public sealed record AlarmQualityUpdate(string AlarmNodeId, OpcUaQuality Quality, DateTime TimestampUtc, AddressSpaceRealm Realm);
|
||||
public sealed record AlarmQualityUpdate(string AlarmNodeId, uint StatusCode, DateTime TimestampUtc, AddressSpaceRealm Realm);
|
||||
/// <summary>
|
||||
/// Triggers an address-space rebuild. <paramref name="DeploymentId"/> is the deployment
|
||||
/// just applied by the host; the rebuild loads THAT artifact so materialisation matches the
|
||||
@@ -306,7 +308,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
try
|
||||
{
|
||||
_sink.WriteValue(msg.NodeId, msg.Value, msg.Quality, msg.TimestampUtc, msg.Realm);
|
||||
_sink.WriteValue(msg.NodeId, msg.Value, msg.StatusCode, msg.TimestampUtc, msg.Realm);
|
||||
Interlocked.Increment(ref _writes);
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "value"));
|
||||
}
|
||||
@@ -334,7 +336,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
try
|
||||
{
|
||||
_sink.WriteAlarmQuality(msg.AlarmNodeId, msg.Quality, msg.TimestampUtc, msg.Realm);
|
||||
_sink.WriteAlarmQuality(msg.AlarmNodeId, msg.StatusCode, msg.TimestampUtc, msg.Realm);
|
||||
Interlocked.Increment(ref _writes);
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "alarm-quality"));
|
||||
}
|
||||
|
||||
@@ -388,28 +388,13 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
// Feed the live value into the upstream the engine subscribes from. #478 — carry the source
|
||||
// driver's quality (mapped to an OPC UA StatusCode) so the engine can derive a scripted
|
||||
// condition's worst-of-input quality; a Bad/Uncertain input is no longer silently treated as Good.
|
||||
// #519 — the status code rides through verbatim. This used to convert the incoming value onto a
|
||||
// 3-state enum and straight back again through a pair of mutually-inverse mappers, which is where
|
||||
// the input's sub-code was lost before the engine ever cached it.
|
||||
_upstream.Push(msg.TagId,
|
||||
new DataValueSnapshot(msg.Value, StatusFromQuality(msg.Quality), msg.TimestampUtc, msg.TimestampUtc));
|
||||
new DataValueSnapshot(msg.Value, msg.StatusCode, msg.TimestampUtc, msg.TimestampUtc));
|
||||
}
|
||||
|
||||
/// <summary>#478 — map the 3-state <see cref="OpcUaQuality"/> to an OPC UA StatusCode (severity bits)
|
||||
/// for the engine's read cache. The inverse of <see cref="QualityFromStatus"/>.</summary>
|
||||
private static uint StatusFromQuality(OpcUaQuality quality) => quality switch
|
||||
{
|
||||
OpcUaQuality.Bad => 0x80000000u,
|
||||
OpcUaQuality.Uncertain => 0x40000000u,
|
||||
_ => 0u, // Good
|
||||
};
|
||||
|
||||
/// <summary>#478 — collapse an engine <see cref="ScriptedAlarmEvent.WorstInputStatusCode"/> (top-2
|
||||
/// severity bits) back to the 3-state <see cref="OpcUaQuality"/> the Commons snapshot / node path use.</summary>
|
||||
private static OpcUaQuality QualityFromStatus(uint statusCode) => (statusCode >> 30) switch
|
||||
{
|
||||
0 => OpcUaQuality.Good,
|
||||
1 => OpcUaQuality.Uncertain,
|
||||
_ => OpcUaQuality.Bad,
|
||||
};
|
||||
|
||||
private void OnEngineEmission(EngineEmission msg)
|
||||
{
|
||||
var e = msg.Event;
|
||||
@@ -430,7 +415,7 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
{
|
||||
_publishActor.Tell(new OpcUaPublishActor.AlarmQualityUpdate(
|
||||
AlarmNodeId: e.AlarmId,
|
||||
Quality: QualityFromStatus(e.WorstInputStatusCode),
|
||||
StatusCode: e.WorstInputStatusCode,
|
||||
TimestampUtc: e.TimestampUtc,
|
||||
Realm: AddressSpaceRealm.Uns));
|
||||
return;
|
||||
@@ -715,10 +700,10 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
Shelving: MapShelving(condition.Shelving.Kind),
|
||||
Severity: (ushort)SeverityToInt(severity),
|
||||
Message: message,
|
||||
// #478 — the condition's Quality is the worst quality across the script's input tags at evaluation
|
||||
// #478 — the condition's status is the worst status across the script's input tags at evaluation
|
||||
// time (carried on the event by the engine). A transition fired while an input is Uncertain projects
|
||||
// Uncertain here so the full-snapshot write doesn't clobber quality back to Good.
|
||||
Quality: QualityFromStatus(worstInputStatusCode));
|
||||
StatusCode: worstInputStatusCode);
|
||||
|
||||
/// <summary>Maps the Core <see cref="ShelvingKind"/> onto the Commons <see cref="AlarmShelvingKind"/>
|
||||
/// mirror (the Commons assembly can't see the Core enum).</summary>
|
||||
|
||||
@@ -100,7 +100,7 @@ public sealed class DependencyMuxActor : ReceiveActor
|
||||
// space carries thousands of tags and only a fraction feed virtual-tag expressions.
|
||||
return;
|
||||
}
|
||||
var dep = new VirtualTagActor.DependencyValueChanged(msg.FullReference, msg.Value, msg.TimestampUtc, msg.Quality);
|
||||
var dep = new VirtualTagActor.DependencyValueChanged(msg.FullReference, msg.Value, msg.TimestampUtc, msg.StatusCode);
|
||||
foreach (var sub in subscribers)
|
||||
{
|
||||
sub.Tell(dep);
|
||||
|
||||
@@ -18,8 +18,8 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||
/// <c>script-logs</c> DPS topic so operators see the diagnostic in the live tail.
|
||||
///
|
||||
/// <para>
|
||||
/// 02/S13 — a persistent script failure/timeout also degrades the node's OPC UA quality to
|
||||
/// <see cref="OpcUaQuality.Bad"/> (carrying the last-known value), published <b>once per
|
||||
/// 02/S13 — a persistent script failure/timeout also degrades the node's OPC UA status to
|
||||
/// <see cref="OpcUaStatus.BadInternalError"/> (carrying the last-known value), published <b>once per
|
||||
/// transition</b> (Good→Bad) and gated on every declared dependency having arrived at least once
|
||||
/// — so a broken script no longer serves its last Good value forever, and a fresh/respawned
|
||||
/// multi-dep script does not flash Bad during deploy warm-up. Recovery to a Good value clears the
|
||||
@@ -30,11 +30,12 @@ public sealed class VirtualTagActor : ReceiveActor
|
||||
{
|
||||
public const string ScriptLogsTopic = "script-logs";
|
||||
|
||||
/// <summary>A dependency's value changed. <paramref name="Quality"/> (#478) carries the source
|
||||
/// driver's OPC UA quality so the scripted-alarm host can derive a condition's worst-of-input quality;
|
||||
/// it defaults to <see cref="OpcUaQuality.Good"/>, and the virtual-tag engine ignores it.</summary>
|
||||
/// <summary>A dependency's value changed. <paramref name="StatusCode"/> (#478) carries the source
|
||||
/// driver's OPC UA status code so the scripted-alarm host can derive a condition's worst-of-input
|
||||
/// quality; it defaults to <see cref="OpcUaStatus.Good"/>, and the virtual-tag engine ignores it.
|
||||
/// Since #519 it carries the driver's sub-code, so a worst-of-input winner keeps the reason it won.</summary>
|
||||
public sealed record DependencyValueChanged(
|
||||
string TagId, object? Value, DateTime TimestampUtc, OpcUaQuality Quality = OpcUaQuality.Good);
|
||||
string TagId, object? Value, DateTime TimestampUtc, uint StatusCode = OpcUaStatus.Good);
|
||||
|
||||
/// <summary>Re-assert the last emitted value/quality to the parent bridge, bypassing the value dedup.
|
||||
/// Sent by <see cref="VirtualTagHostActor"/> on every apply (deploy) to a surviving child: a deploy
|
||||
@@ -43,9 +44,9 @@ public sealed class VirtualTagActor : ReceiveActor
|
||||
/// node would stay Bad forever. Re-asserting the last state recovers it. Singleton — carries no data.</summary>
|
||||
public sealed class ReassertValue { public static readonly ReassertValue Instance = new(); private ReassertValue() { } }
|
||||
|
||||
/// <summary>Result emitted to the parent (the host bridge). <paramref name="Quality"/> carries the
|
||||
/// OPC UA quality the node should read: <see cref="OpcUaQuality.Good"/> for a fresh computed value,
|
||||
/// <see cref="OpcUaQuality.Bad"/> when the script failed/timed out (02/S13) — in which case
|
||||
/// <summary>Result emitted to the parent (the host bridge). <paramref name="StatusCode"/> carries the
|
||||
/// OPC UA status code the node should read: <see cref="OpcUaStatus.Good"/> for a fresh computed value,
|
||||
/// <see cref="OpcUaStatus.BadInternalError"/> when the script failed/timed out (02/S13) — in which case
|
||||
/// <paramref name="Value"/> is the last-known value (or null if none). Defaulting to Good keeps
|
||||
/// every existing construction site source-compatible.</summary>
|
||||
/// <param name="IsReassert"><c>true</c> when this result is a deploy-time re-assertion of the last
|
||||
@@ -53,7 +54,7 @@ public sealed class VirtualTagActor : ReceiveActor
|
||||
/// publishes it (to repair the reset OPC UA node) but must NOT re-record it to the historian — it is a
|
||||
/// stale value stamped with a fresh deploy-time timestamp, never a real evaluation sample. Defaults to
|
||||
/// <c>false</c> so every existing construction site stays source-compatible.</param>
|
||||
public sealed record EvaluationResult(string VirtualTagId, object? Value, DateTime TimestampUtc, CorrelationId Correlation, OpcUaQuality Quality = OpcUaQuality.Good, bool IsReassert = false);
|
||||
public sealed record EvaluationResult(string VirtualTagId, object? Value, DateTime TimestampUtc, CorrelationId Correlation, uint StatusCode = OpcUaStatus.Good, bool IsReassert = false);
|
||||
|
||||
private readonly string _virtualTagId;
|
||||
private readonly string _scriptId;
|
||||
@@ -170,9 +171,9 @@ public sealed class VirtualTagActor : ReceiveActor
|
||||
private void OnReassertValue()
|
||||
{
|
||||
if (!_hasLastValue && !_lastPublishedBad) return;
|
||||
var quality = _lastPublishedBad ? OpcUaQuality.Bad : OpcUaQuality.Good;
|
||||
var statusCode = _lastPublishedBad ? OpcUaStatus.BadInternalError : OpcUaStatus.Good;
|
||||
Context.Parent.Tell(new EvaluationResult(
|
||||
_virtualTagId, _lastValue, DateTime.UtcNow, CorrelationId.NewId(), quality, IsReassert: true));
|
||||
_virtualTagId, _lastValue, DateTime.UtcNow, CorrelationId.NewId(), statusCode, IsReassert: true));
|
||||
}
|
||||
|
||||
private void OnDependencyChanged(DependencyValueChanged msg)
|
||||
@@ -242,7 +243,7 @@ public sealed class VirtualTagActor : ReceiveActor
|
||||
|
||||
_lastPublishedBad = true;
|
||||
Context.Parent.Tell(new EvaluationResult(
|
||||
_virtualTagId, _hasLastValue ? _lastValue : null, timestampUtc, CorrelationId.NewId(), OpcUaQuality.Bad));
|
||||
_virtualTagId, _hasLastValue ? _lastValue : null, timestampUtc, CorrelationId.NewId(), OpcUaStatus.BadInternalError));
|
||||
}
|
||||
|
||||
/// <summary>Inputs-ready gate (mirrors the dormant <c>VirtualTagEngine.AreInputsReady</c>): true once
|
||||
|
||||
@@ -221,11 +221,11 @@ public sealed class VirtualTagHostActor : ReceiveActor
|
||||
}
|
||||
|
||||
// 02/S13: the child now expresses quality — a Good fresh value or a Bad degradation (script
|
||||
// failure/timeout) carrying the last-known value. Bridge result.Quality through verbatim; the
|
||||
// failure/timeout) carrying the last-known value. Bridge result.StatusCode through verbatim; the
|
||||
// sink maps it to StatusCodes.Good/Bad on the node so a broken script no longer freezes Good.
|
||||
// VirtualTags materialise as equipment nodes in the UNS realm — publish there.
|
||||
_publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
|
||||
nodeId, result.Value, result.Quality, result.TimestampUtc, AddressSpaceRealm.Uns));
|
||||
nodeId, result.Value, result.StatusCode, result.TimestampUtc, AddressSpaceRealm.Uns));
|
||||
|
||||
// Historize iff the plan opted in — but NEVER for a re-assert (M1). A re-assert re-publishes a
|
||||
// stale last value stamped with a fresh deploy-time timestamp purely to repair the reset OPC UA
|
||||
@@ -233,14 +233,12 @@ public sealed class VirtualTagHostActor : ReceiveActor
|
||||
// evaluation (and, if the last state was Bad, a fabricated BadInternalError point). Reuses
|
||||
// _planByVtag (kept in lock-step with _children), so no parallel map. The historian path key is the
|
||||
// SAME folder-scoped NodeId we just published to. For a computed value source == server, so both
|
||||
// timestamps are the evaluation time. Bad results record BadInternalError (0x80020000u) — the
|
||||
// dormant VirtualTagEngine's Bad-snapshot status — so the historian trend can distinguish a degraded
|
||||
// sample from a Good one.
|
||||
// timestamps are the evaluation time. Since #519 the result's status code IS the historian status —
|
||||
// the two used to be derived separately here, which is why this line existed at all.
|
||||
if (!result.IsReassert && _planByVtag.TryGetValue(result.VirtualTagId, out var plan) && plan.Historize)
|
||||
{
|
||||
var status = result.Quality == OpcUaQuality.Good ? 0u : 0x80020000u /* BadInternalError — dormant-engine parity */;
|
||||
_history.Record(nodeId, new DataValueSnapshot(
|
||||
result.Value, status, result.TimestampUtc, result.TimestampUtc));
|
||||
result.Value, result.StatusCode, result.TimestampUtc, result.TimestampUtc));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user