feat(alarms): condition Quality tracks source connectivity (#477)
v2-ci / build (pull_request) Successful in 4m49s
v2-ci / unit-tests (pull_request) Failing after 3h11m25s

Part 9 ConditionType.Quality was never assigned; default(StatusCode)==Good
so every native + scripted condition reported Good unconditionally — a
comms-lost device still showed a healthy, inactive, Good condition (a
wrong-VALUE bug, distinct from the null-value #473/#475). Clients (and HMIs
bucketing on IsGood) could not tell "genuinely inactive" from "lost contact".

Layer 1 — make Quality a real, plumbed field:
- AlarmConditionSnapshot gains OpcUaQuality Quality (default Good).
- MaterialiseAlarmCondition sets it (native BadWaitingForInitialData, scripted Good).
- WriteAlarmCondition projects snapshot.Quality; the delta-gate gains a Quality
  member so a quality-bucket change fires a Part 9 event.

Layer 2 — drive native quality from driver connectivity (a comms-lost driver
emits no alarm transitions, and an alarm-bearing raw tag has no value variable,
so quality can't come from either existing channel):
- DriverInstanceActor Tells parent ConnectivityChanged on Connected/Reconnecting.
- DriverHostActor fans it to every native condition the driver owns as
  OpcUaPublishActor.AlarmQualityUpdate (Good on connect, Bad on disconnect).
- New dedicated IOpcUaAddressSpaceSink.WriteAlarmQuality sets ONLY Quality and
  fires only on a bucket change — never touches Active/Acked/Retain (an active
  alarm that loses comms stays active). Not a full-snapshot re-projection, so it
  can't clobber severity/message and works for a never-fired condition.
  Forwarded through DeferredAddressSpaceSink (F10b trap; auto-verified by the
  reflection forwarding guard). Ungated by redundancy role; no /alerts row.

Scripted conditions stay Good; worst-of-input quality deferred to #478 (Layer 3).

Tests: node-level (materialise/project/no-clobber/unknown-node no-op),
NativeAlarmProjector, DriverInstanceActor connectivity emission, DriverHostActor
fan-out, OpcUaPublishActor routing, and the wire-level guard
(Condition_event_Quality_tracks_source_connectivity_on_the_wire) — RED-verified
against a simulated pre-fix always-Good server. Existing DriverInstanceActor
parent probes ignore the new ConnectivityChanged.

Docs: docs/AlarmTracking.md §"Condition source-data Quality (#477)";
design doc docs/plans/2026-07-17-alarm-condition-quality-477-design.md.
This commit is contained in:
Joseph Doherty
2026-07-17 15:10:04 -04:00
parent f6a3c31b60
commit db751d12a5
30 changed files with 752 additions and 7 deletions
@@ -16,6 +16,12 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
/// <param name="Shelving">The shelving mode (ShelvingState): unshelved, one-shot, or timed.</param>
/// <param name="Severity">OPC UA severity on the 1..1000 scale (the SDK <c>SetSeverity</c> input).</param>
/// <param name="Message">The human-readable condition message (LocalizedText payload).</param>
/// <param name="Quality">
/// Quality of the condition's source data (Part 9 <c>ConditionType.Quality</c>). Carried so a
/// comms-lost native source can report a non-<c>Good</c> condition instead of the accidentally-Good
/// default (issue #477). It is a pure annotation — it never alters Active/Acked/Retain. Defaults to
/// <see cref="OpcUaQuality.Good"/> so scripted-alarm callers (which stay Good in v1) need not supply it.
/// </param>
public sealed record AlarmConditionSnapshot(
bool Active,
bool Acknowledged,
@@ -23,7 +29,8 @@ public sealed record AlarmConditionSnapshot(
bool Enabled,
AlarmShelvingKind Shelving,
ushort Severity,
string Message);
string Message,
OpcUaQuality Quality = OpcUaQuality.Good);
/// <summary>
/// Commons-local mirror of the Core <c>ShelvingKind</c> enum so this assembly carries no
@@ -30,6 +30,9 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _inner.WriteAlarmQuality(alarmNodeId, quality, sourceTimestampUtc, realm);
/// <inheritdoc />
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
@@ -33,6 +33,17 @@ public interface IOpcUaAddressSpaceSink
/// the condition was materialised under).</param>
void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
/// <summary>#477 — annotate a materialised condition's source-data quality OUT OF BAND from any alarm
/// transition (used by the driver-connectivity path: comms lost → <see cref="OpcUaQuality.Bad"/>,
/// restored → <see cref="OpcUaQuality.Good"/>). Sets ONLY the condition's Quality — never
/// Active/Acked/Severity/Retain (a comms-lost active alarm stays active) — and fires one Part 9 event
/// only on a quality-bucket change. A no-op for an unmaterialised / non-condition node.</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="sourceTimestampUtc">The connectivity transition timestamp in UTC.</param>
/// <param name="realm">The namespace realm the condition was materialised under.</param>
void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
/// <summary>
/// Materialise a real OPC UA Part 9 alarm-condition node under its equipment folder so clients
/// can browse it as a proper condition (with basic Active/Ack state). The node id equals the
@@ -165,6 +176,7 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
/// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
/// <inheritdoc />
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
@@ -497,6 +497,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
condition.SetSeverity(SystemContext, MapSeverity(state.Severity));
condition.Message.Value = new LocalizedText(state.Message);
// #477 — project the source-data quality. A Good→Bad bucket change is a genuine condition
// change (it's in the delta-gate above), so it fires a Part 9 event carrying the new Quality;
// it never touches Active/Acked/Retain (annotation only). Quality is an optional Part 9 child —
// 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);
if (condition.Quality.SourceTimestamp is not null) condition.Quality.SourceTimestamp.Value = sourceTimestampUtc;
}
// Part 9: retain the condition while it is active OR unacknowledged so a client's
// ConditionRefresh replays it. The event firing below also depends on this Retain being
// correct (a non-retained inactive+acked condition still fires its transition event, but
@@ -535,6 +545,50 @@ 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
/// 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 ⇒
/// safe no-op (a mid-rebuild race must not fault a connectivity update), mirroring the other writes.
/// </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="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)
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
EnsureAddressSpaceCreated();
var key = MapKey(realm, alarmNodeId);
lock (Lock)
{
// Only materialised conditions carry a Quality child; a bare value variable or a missing node is
// a no-op (the connectivity fan-out visits every condition the driver owns, some of which a
// concurrent rebuild may have just cleared).
if (!_alarmConditions.TryGetValue(key, out var condition) || condition.Quality is null)
return;
var newCode = StatusFromQuality(quality);
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.
if (changed)
{
condition.Time.Value = sourceTimestampUtc;
condition.ReceiveTime.Value = sourceTimestampUtc;
ReportConditionEvent(condition, sourceTimestampUtc);
}
}
}
/// <summary>
/// Fire a real OPC UA Part 9 condition event for one engine-driven state transition on a
/// materialised <see cref="AlarmConditionState"/>. The caller MUST already hold <c>Lock</c> and
@@ -650,7 +704,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
bool Enabled,
AlarmShelvingKind Shelving,
ushort MappedSeverity,
string Message);
string Message,
// #477 — the source-data quality bucket. Included so a quality-only transition (e.g. a device
// going comms-lost: Good→Bad with no state change) is a genuine delta and fires a Part 9 event.
// StatusCode has value equality, so the record struct's == still holds.
StatusCode Quality);
/// <summary>Decide whether a <see cref="WriteAlarmCondition"/> projection is a genuine state change
/// (and so should fire a Part 9 condition event) by comparing the node's pre-projection state to the
@@ -676,7 +734,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
Enabled: condition.EnabledState?.Id?.Value ?? true,
Shelving: ReadShelvingKind(condition),
MappedSeverity: condition.Severity?.Value ?? (ushort)0,
Message: condition.Message?.Value?.Text ?? string.Empty);
Message: condition.Message?.Value?.Text ?? string.Empty,
// Optional Part 9 child; a node without it reads as Good (matching the accidentally-Good default
// and ToConditionDelta's fold), so a snapshot Quality can't create a phantom delta against it.
Quality: condition.Quality?.Value ?? StatusCodes.Good);
/// <summary>Build the gate-relevant slice from the incoming snapshot, normalising the two fields that
/// the node stores in a derived form: Severity is run through <see cref="MapSeverity"/> so it matches
@@ -694,7 +755,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// node's read-back default (Unshelved).
Shelving: condition.ShelvingState is not null ? state.Shelving : AlarmShelvingKind.Unshelved,
MappedSeverity: (ushort)MapSeverity(state.Severity),
Message: state.Message ?? string.Empty);
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);
/// <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
@@ -827,6 +891,20 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (alarm.ConditionClassId is not null) alarm.ConditionClassId.Value = ObjectTypeIds.BaseConditionClassType;
if (alarm.ConditionClassName is not null) alarm.ConditionClassName.Value = new LocalizedText("BaseConditionClass");
// #477 — ConditionType.Quality (the quality of the condition's source data). Create() leaves it
// UNSET, and default(StatusCode) == StatusCodes.Good (0x0), so an unassigned Quality reports Good
// unconditionally — a wrong VALUE (not a null), which hides a comms-lost source. A NATIVE condition
// has no data yet at materialise (its driver hasn't confirmed connectivity), so it starts
// BadWaitingForInitialData — the same "no driver data yet" convention value variables use — and is
// driven Good by the driver-connectivity path (DriverHostActor → ProjectQuality) once Connected. A
// SCRIPTED condition is script-computed and always live in v1, so it starts Good. Quality is a pure
// annotation: it NEVER alters Active/Acked/Retain (a comms-lost active alarm must stay active).
if (alarm.Quality is not null)
{
alarm.Quality.Value = isNative ? StatusCodes.BadWaitingForInitialData : StatusCodes.Good;
if (alarm.Quality.SourceTimestamp is not null) alarm.Quality.SourceTimestamp.Value = DateTime.UtcNow;
}
// Initial state via the SDK setters (T14: basic state only, NO event firing).
alarm.SetEnableState(SystemContext, true);
alarm.SetActiveState(SystemContext, false);
@@ -28,6 +28,9 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
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);
/// <inheritdoc />
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
=> _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
@@ -570,6 +570,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
Receive<GetDiagnostics>(HandleGetDiagnostics);
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
Receive<DriverInstanceActor.DiscoveredNodesReady>(HandleDiscoveredNodes);
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
Receive<RestartDriver>(HandleRestartDriver);
@@ -600,6 +601,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
Receive<GetDiagnostics>(HandleGetDiagnostics);
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
Receive<DriverInstanceActor.DiscoveredNodesReady>(HandleDiscoveredNodes);
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
Receive<RestartDriver>(HandleRestartDriver);
@@ -1010,6 +1012,46 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// signal the inbound-write gate uses — only the Primary publishes the single fleet-wide copy.
/// </para>
/// </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
/// 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>
/// UNGATED by redundancy role (like the condition write in <see cref="ForwardNativeAlarm"/>): a
/// Secondary keeps its address space — including condition quality — warm for failover. Quality is
/// a pure annotation: <c>WriteAlarmQuality</c> touches ONLY the condition's Quality, never its
/// Active/Acked/Retain, and fires a Part 9 event only on a real quality-bucket change. No cluster
/// <c>alerts</c> row is published here — driver comms health has its own status surface
/// (<see cref="IDriverHealthPublisher"/>); a row per condition would be alarm-fatigue.
/// </para>
/// </summary>
private void OnDriverConnectivityChanged(DriverInstanceActor.ConnectivityChanged msg)
{
if (_opcUaPublishActor is null) return;
var quality = msg.Connected ? OpcUaQuality.Good : OpcUaQuality.Bad;
var ts = DateTime.UtcNow;
var annotated = 0;
// Fan out to every condition this driver owns. _alarmNodeIdByDriverRef is keyed by
// (DriverInstanceId, RawPath); one driver ref can back several condition NodeIds (identical machines).
foreach (var ((driverId, _), nodeIds) in _alarmNodeIdByDriverRef)
{
if (driverId != msg.DriverInstanceId) continue;
foreach (var n in nodeIds)
{
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmQualityUpdate(
n.NodeId, quality, 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);
}
private void ForwardNativeAlarm(DriverInstanceActor.AttributeAlarmPublished msg)
{
if (_opcUaPublishActor is null) return;
@@ -1326,6 +1368,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
// applied yet, so the equipment can't be resolved). Drop it — the re-discovery loop re-sends it
// and the post-recovery re-apply self-heals it once an apply runs (matches the no-op drops above).
Receive<DriverInstanceActor.DiscoveredNodesReady>(_ => { });
// A child connectivity transition while the host is Stale has no live address space to annotate — drop
// it (the post-recovery rebuild re-materialises conditions, and the child re-announces on its next entry).
Receive<DriverInstanceActor.ConnectivityChanged>(_ => { });
// A late DeltaApplied (an apply completed just before the DB went Stale) — re-register the driver's
// mux adapter anyway; it simply re-reads the driver's current refs (harmless, no DB access).
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
@@ -46,6 +46,12 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
public sealed record InitializeSucceeded(int Generation);
public sealed record InitializeFailed(string Reason, int Generation);
public sealed record DisconnectObserved(string Reason);
/// <summary>#477 — sent to the parent (<see cref="DriverHostActor"/>) on every connectivity transition:
/// <c>Connected=true</c> on entering the Connected state, <c>false</c> on entering Reconnecting. The host
/// annotates this driver's native alarm conditions' source-data Quality from it (comms lost → Bad,
/// restored → Good) — independently of alarm transitions, since a comms-lost driver emits no alarm
/// events. Fire-and-forget, mirroring <see cref="DeltaApplied"/>.</summary>
public sealed record ConnectivityChanged(string DriverInstanceId, bool Connected);
public sealed record ApplyDelta(string DriverConfigJson, CorrelationId Correlation);
public sealed record ApplyResult(bool Success, string? Reason, CorrelationId Correlation);
/// <summary>
@@ -413,6 +419,11 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
private void Connected()
{
// #477 — announce connectivity to the host so it can clear any comms-lost Quality annotation on this
// driver's native alarm conditions (Bad → Good). Fire-and-forget; the host defaults conditions to a
// non-Good "waiting for initial data" quality at materialise, and this is what confirms them Good.
Context.Parent.Tell(new ConnectivityChanged(_driverInstanceId, Connected: true));
ReceiveAsync<ApplyDelta>(HandleApplyDeltaAsync);
Receive<DisconnectObserved>(msg =>
{
@@ -483,6 +494,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
private void Reconnecting()
{
// #477 — announce comms loss to the host so it annotates this driver's native alarm conditions Bad
// (a comms-lost driver emits no alarm events, so this is the ONLY signal that the source is unreachable).
Context.Parent.Tell(new ConnectivityChanged(_driverInstanceId, Connected: false));
Receive<RetryConnect>(_ => InitializeAsync(_currentConfigJson ?? "{}"));
// Fast-fail writes while reconnecting (same reason as Connecting — avoids the 8s host Ask
// timeout on an inbound write to a transiently-down driver). Synchronous Receive.
@@ -58,6 +58,15 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// <param name="Realm">The namespace realm the condition lives in — <see cref="AddressSpaceRealm.Uns"/> for
/// 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>
/// <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="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);
/// <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
@@ -239,6 +248,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
Receive<AttributeValueUpdate>(HandleAttributeUpdate);
Receive<AlarmStateUpdate>(HandleAlarmUpdate);
Receive<AlarmQualityUpdate>(HandleAlarmQualityUpdate);
Receive<RebuildAddressSpace>(HandleRebuild);
Receive<MaterialiseDiscoveredNodes>(HandleMaterialiseDiscovered);
Receive<ServiceLevelChanged>(HandleServiceLevelChanged);
@@ -296,6 +306,20 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
}
}
private void HandleAlarmQualityUpdate(AlarmQualityUpdate msg)
{
try
{
_sink.WriteAlarmQuality(msg.AlarmNodeId, msg.Quality, msg.TimestampUtc, msg.Realm);
Interlocked.Increment(ref _writes);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "alarm-quality"));
}
catch (Exception ex)
{
_log.Warning(ex, "OpcUaPublish: sink.WriteAlarmQuality threw for {Node}", msg.AlarmNodeId);
}
}
private void HandleRebuild(RebuildAddressSpace msg)
{
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
@@ -539,7 +539,10 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
Enabled: e.Condition.Enabled == AlarmEnabledState.Enabled,
Shelving: MapShelving(e.Condition.Shelving.Kind),
Severity: (ushort)SeverityToInt(e.Severity),
Message: e.Message);
Message: e.Message,
// #477 — scripted conditions are script-computed and always live in this scope, so they report Good.
// Deriving quality from the worst of a script's input-tag qualities is a deferred follow-up (Layer 3).
Quality: OpcUaQuality.Good);
/// <summary>Maps the Core <see cref="ShelvingKind"/> onto the Commons <see cref="AlarmShelvingKind"/>
/// mirror (the Commons assembly can't see the Core enum).</summary>