8c5e2be92e
Layer 3 of #477: a scripted alarm's condition Quality now reflects the WORST quality across its input tags, mirroring the native OT semantic (#477 L2). Plumbing (quality was silently discarded twice on the live path): - VirtualTagActor.DependencyValueChanged gains Quality (defaulted Good); the DependencyMuxActor forwards the published AttributeValuePublished.Quality it already carried; ScriptedAlarmHostActor.OnDependencyChanged pushes the real quality into the engine (was hardcoded 0u/Good). Engine (Core.ScriptedAlarms): - ScriptedAlarmEngine computes worst-of-input quality each eval (skipping not-yet-published inputs, which are a readiness concern, not a quality signal) and carries it on ScriptedAlarmEvent.WorstInputStatusCode. - A real transition carries the current worst quality so ToSnapshot's full snapshot doesn't clobber quality back to Good (e.g. transition while Uncertain). - A Bad input freezes the condition (no transition), like a comms-lost native driver; a quality-bucket change with no transition emits the new EmissionKind.QualityChanged, routed to the existing #477-L2 AlarmQualityUpdate -> WriteAlarmQuality node path (quality only, no /alerts row, no historian write). ScriptedAlarmSource skips QualityChanged so it never fabricates a phantom IAlarmSource event. Host: ToSnapshot maps WorstInputStatusCode -> OpcUaQuality; OnEngineEmission routes QualityChanged out of band. Tests (TDD, RED-first): engine worst-carry + Bad/restore QualityChanged + unchanged-bucket-no-emit; source swallows QualityChanged; mux forwards quality; host Bad-dep -> AlarmQualityUpdate(no alerts) + transition snapshot carries worst. Docs: AlarmTracking.md Layer-3 section + design doc. Closes #478
138 lines
6.0 KiB
C#
138 lines
6.0 KiB
C#
using System.Collections.Concurrent;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
|
|
|
/// <summary>
|
|
/// Adapter that exposes <see cref="ScriptedAlarmEngine"/> through the driver-agnostic
|
|
/// <see cref="IAlarmSource"/> surface. The existing Phase 6.1 <c>AlarmTracker</c>
|
|
/// composition fan-out consumes this alongside Galaxy / AB CIP / FOCAS alarm
|
|
/// sources — no per-source branching in the fan-out.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Per Phase 7 plan Stream C.6, ack / confirm / shelve / unshelve are OPC UA
|
|
/// method calls per-condition. This adapter implements <see cref="AcknowledgeAsync"/>
|
|
/// from the base interface; the richer Part 9 methods (Confirm / Shelve /
|
|
/// Unshelve / AddComment) live directly on the engine, invoked from OPC UA
|
|
/// method handlers wired up in Stream G.
|
|
/// </para>
|
|
/// <para>
|
|
/// SubscribeAlarmsAsync takes a list of source-node-id filters (typically an
|
|
/// Equipment path prefix). When the list is empty every alarm matches. The
|
|
/// adapter doesn't maintain per-subscription state beyond the filter set — it
|
|
/// checks each emission against every live subscription.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class ScriptedAlarmSource : IAlarmSource, IDisposable
|
|
{
|
|
private readonly ScriptedAlarmEngine _engine;
|
|
private readonly ConcurrentDictionary<string, Subscription> _subscriptions
|
|
= new(StringComparer.Ordinal);
|
|
private bool _disposed;
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="ScriptedAlarmSource"/> class.</summary>
|
|
/// <param name="engine">The scripted alarm engine to expose.</param>
|
|
public ScriptedAlarmSource(ScriptedAlarmEngine engine)
|
|
{
|
|
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
|
|
_engine.OnEvent += OnEngineEvent;
|
|
}
|
|
|
|
/// <summary>Occurs when an alarm event is raised by the engine.</summary>
|
|
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
|
|
|
/// <inheritdoc />
|
|
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
|
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
|
{
|
|
if (sourceNodeIds is null) throw new ArgumentNullException(nameof(sourceNodeIds));
|
|
var handle = new SubscriptionHandle(Guid.NewGuid().ToString("N"));
|
|
_subscriptions[handle.DiagnosticId] = new Subscription(handle,
|
|
new HashSet<string>(sourceNodeIds, StringComparer.Ordinal));
|
|
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
|
{
|
|
if (handle is null) throw new ArgumentNullException(nameof(handle));
|
|
_subscriptions.TryRemove(handle.DiagnosticId, out _);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AcknowledgeAsync(
|
|
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
|
{
|
|
if (acknowledgements is null) throw new ArgumentNullException(nameof(acknowledgements));
|
|
foreach (var a in acknowledgements)
|
|
{
|
|
// Honor the authenticated principal carried on the request when present
|
|
// (the OPC UA dispatch layer / Stream G threads it through). When absent —
|
|
// the raw/non-OPC-UA path — default to "opcua-client" so the ack still
|
|
// produces an audit entry.
|
|
await _engine.AcknowledgeAsync(a.ConditionId, a.OperatorUser ?? "opcua-client", a.Comment, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private void OnEngineEvent(object? sender, ScriptedAlarmEvent evt)
|
|
{
|
|
if (_disposed) return;
|
|
|
|
// #478 — QualityChanged is a source-quality annotation, not a Part 9 state change. It is delivered
|
|
// out of band via the dedicated WriteAlarmQuality node path; surfacing it here would fabricate a
|
|
// phantom AlarmEventArgs that materializes / historizes a condition. Swallow it.
|
|
if (evt.Emission == EmissionKind.QualityChanged) return;
|
|
|
|
foreach (var sub in _subscriptions.Values)
|
|
{
|
|
if (!Matches(sub, evt)) continue;
|
|
var payload = new AlarmEventArgs(
|
|
SubscriptionHandle: sub.Handle,
|
|
SourceNodeId: evt.EquipmentPath,
|
|
ConditionId: evt.AlarmId,
|
|
AlarmType: evt.Kind.ToString(),
|
|
Message: evt.Message,
|
|
Severity: evt.Severity,
|
|
SourceTimestampUtc: evt.TimestampUtc);
|
|
try { OnAlarmEvent?.Invoke(this, payload); }
|
|
catch { /* subscriber exceptions don't crash the adapter */ }
|
|
}
|
|
}
|
|
|
|
private static bool Matches(Subscription sub, ScriptedAlarmEvent evt)
|
|
{
|
|
if (sub.Filter.Count == 0) return true;
|
|
// A subscription matches if any filter is a prefix of the alarm's equipment
|
|
// path — typical use is "Enterprise/Site/Area/Line" filtering a whole line.
|
|
foreach (var f in sub.Filter)
|
|
{
|
|
if (evt.EquipmentPath.Equals(f, StringComparison.Ordinal)) return true;
|
|
if (evt.EquipmentPath.StartsWith(f + "/", StringComparison.Ordinal)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// <summary>Releases resources used by the <see cref="ScriptedAlarmSource"/>.</summary>
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
_engine.OnEvent -= OnEngineEvent;
|
|
_subscriptions.Clear();
|
|
}
|
|
|
|
private sealed class SubscriptionHandle : IAlarmSubscriptionHandle
|
|
{
|
|
/// <summary>Initializes a new instance of the <see cref="SubscriptionHandle"/> class.</summary>
|
|
/// <param name="id">The diagnostic ID for this subscription handle.</param>
|
|
public SubscriptionHandle(string id) { DiagnosticId = id; }
|
|
/// <inheritdoc />
|
|
public string DiagnosticId { get; }
|
|
}
|
|
|
|
private sealed record Subscription(SubscriptionHandle Handle, IReadOnlySet<string> Filter);
|
|
}
|