261 lines
10 KiB
C#
261 lines
10 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Alarms;
|
|
|
|
/// <summary>
|
|
/// Subscribes to the four Galaxy alarm attributes (<c>.InAlarm</c>, <c>.Priority</c>,
|
|
/// <c>.DescAttrName</c>, <c>.Acked</c>) per alarm-bearing attribute discovered during
|
|
/// <c>DiscoverAsync</c>. Maintains one <see cref="AlarmState"/> per alarm, raises
|
|
/// <see cref="AlarmTransition"/> on lifecycle transitions (Active / Unacknowledged /
|
|
/// Acknowledged / Inactive). Ack path writes <c>.AckMsg</c>. Pure-logic state machine
|
|
/// with delegate-based subscribe/write so it's testable against in-memory fakes.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Transitions emitted (OPC UA Part 9 alarm lifecycle, simplified for the Galaxy model):
|
|
/// <list type="bullet">
|
|
/// <item><c>Active</c> — InAlarm false → true. Default to Unacknowledged.</item>
|
|
/// <item><c>Acknowledged</c> — Acked false → true while InAlarm is still true.</item>
|
|
/// <item><c>Inactive</c> — InAlarm true → false. If still unacknowledged the alarm
|
|
/// is marked latched-inactive-unack; next Ack transitions straight to Inactive.</item>
|
|
/// </list>
|
|
/// </remarks>
|
|
public sealed class GalaxyAlarmTracker : IDisposable
|
|
{
|
|
public const string InAlarmAttr = ".InAlarm";
|
|
public const string PriorityAttr = ".Priority";
|
|
public const string DescAttrNameAttr = ".DescAttrName";
|
|
public const string AckedAttr = ".Acked";
|
|
public const string AckMsgAttr = ".AckMsg";
|
|
|
|
private readonly Func<string, Action<string, Vtq>, Task> _subscribe;
|
|
private readonly Func<string, Task> _unsubscribe;
|
|
private readonly Func<string, object, Task<bool>> _write;
|
|
private readonly Func<DateTime> _clock;
|
|
|
|
// Alarm tag (attribute full ref, e.g. "Tank.Level.HiHi") → state.
|
|
private readonly ConcurrentDictionary<string, AlarmState> _alarms =
|
|
new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
// Reverse lookup: probed tag (".InAlarm" etc.) → owning alarm tag.
|
|
private readonly ConcurrentDictionary<string, (string AlarmTag, AlarmField Field)> _probeToAlarm =
|
|
new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
private bool _disposed;
|
|
|
|
public event EventHandler<AlarmTransition>? TransitionRaised;
|
|
|
|
public GalaxyAlarmTracker(
|
|
Func<string, Action<string, Vtq>, Task> subscribe,
|
|
Func<string, Task> unsubscribe,
|
|
Func<string, object, Task<bool>> write)
|
|
: this(subscribe, unsubscribe, write, () => DateTime.UtcNow) { }
|
|
|
|
internal GalaxyAlarmTracker(
|
|
Func<string, Action<string, Vtq>, Task> subscribe,
|
|
Func<string, Task> unsubscribe,
|
|
Func<string, object, Task<bool>> write,
|
|
Func<DateTime> clock)
|
|
{
|
|
_subscribe = subscribe ?? throw new ArgumentNullException(nameof(subscribe));
|
|
_unsubscribe = unsubscribe ?? throw new ArgumentNullException(nameof(unsubscribe));
|
|
_write = write ?? throw new ArgumentNullException(nameof(write));
|
|
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
|
}
|
|
|
|
public int TrackedAlarmCount => _alarms.Count;
|
|
|
|
/// <summary>
|
|
/// Advise the four alarm attributes for <paramref name="alarmTag"/>. Idempotent —
|
|
/// repeat calls for the same alarm tag are a no-op. Subscribe failure for any of the
|
|
/// four rolls back the alarm entry so a stale callback cannot promote a phantom.
|
|
/// </summary>
|
|
public async Task TrackAsync(string alarmTag)
|
|
{
|
|
if (_disposed || string.IsNullOrWhiteSpace(alarmTag)) return;
|
|
if (_alarms.ContainsKey(alarmTag)) return;
|
|
|
|
var state = new AlarmState { AlarmTag = alarmTag };
|
|
if (!_alarms.TryAdd(alarmTag, state)) return;
|
|
|
|
var probes = new[]
|
|
{
|
|
(Tag: alarmTag + InAlarmAttr, Field: AlarmField.InAlarm),
|
|
(Tag: alarmTag + PriorityAttr, Field: AlarmField.Priority),
|
|
(Tag: alarmTag + DescAttrNameAttr, Field: AlarmField.DescAttrName),
|
|
(Tag: alarmTag + AckedAttr, Field: AlarmField.Acked),
|
|
};
|
|
|
|
foreach (var p in probes)
|
|
{
|
|
_probeToAlarm[p.Tag] = (alarmTag, p.Field);
|
|
}
|
|
|
|
try
|
|
{
|
|
foreach (var p in probes)
|
|
{
|
|
await _subscribe(p.Tag, OnProbeCallback).ConfigureAwait(false);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Rollback so a partial advise doesn't leak state.
|
|
_alarms.TryRemove(alarmTag, out _);
|
|
foreach (var p in probes)
|
|
{
|
|
_probeToAlarm.TryRemove(p.Tag, out _);
|
|
try { await _unsubscribe(p.Tag).ConfigureAwait(false); } catch { }
|
|
}
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drop every tracked alarm. Unadvises all 4 probes per alarm as best-effort.
|
|
/// </summary>
|
|
public async Task ClearAsync()
|
|
{
|
|
_alarms.Clear();
|
|
foreach (var kv in _probeToAlarm.ToList())
|
|
{
|
|
_probeToAlarm.TryRemove(kv.Key, out _);
|
|
try { await _unsubscribe(kv.Key).ConfigureAwait(false); } catch { }
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Operator ack — write the comment text into <c><alarmTag>.AckMsg</c>.
|
|
/// Returns false when the runtime reports the write failed.
|
|
/// </summary>
|
|
public Task<bool> AcknowledgeAsync(string alarmTag, string comment)
|
|
{
|
|
if (_disposed || string.IsNullOrWhiteSpace(alarmTag))
|
|
return Task.FromResult(false);
|
|
return _write(alarmTag + AckMsgAttr, comment ?? string.Empty);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Subscription callback entry point. Exposed for tests and for the Backend to route
|
|
/// fan-out callbacks through. Runs the state machine and fires TransitionRaised
|
|
/// outside the lock.
|
|
/// </summary>
|
|
public void OnProbeCallback(string probeTag, Vtq vtq)
|
|
{
|
|
if (_disposed) return;
|
|
if (!_probeToAlarm.TryGetValue(probeTag, out var link)) return;
|
|
if (!_alarms.TryGetValue(link.AlarmTag, out var state)) return;
|
|
|
|
AlarmTransition? transition = null;
|
|
var now = _clock();
|
|
|
|
lock (state.Lock)
|
|
{
|
|
switch (link.Field)
|
|
{
|
|
case AlarmField.InAlarm:
|
|
{
|
|
var wasActive = state.InAlarm;
|
|
var isActive = vtq.Value is bool b && b;
|
|
state.InAlarm = isActive;
|
|
state.LastUpdateUtc = now;
|
|
if (!wasActive && isActive)
|
|
{
|
|
state.Acked = false;
|
|
state.LastTransitionUtc = now;
|
|
transition = new AlarmTransition(state.AlarmTag, AlarmStateTransition.Active, state.Priority, state.DescAttrName, now);
|
|
}
|
|
else if (wasActive && !isActive)
|
|
{
|
|
state.LastTransitionUtc = now;
|
|
transition = new AlarmTransition(state.AlarmTag, AlarmStateTransition.Inactive, state.Priority, state.DescAttrName, now);
|
|
}
|
|
break;
|
|
}
|
|
case AlarmField.Priority:
|
|
if (vtq.Value is int pi) state.Priority = pi;
|
|
else if (vtq.Value is short ps) state.Priority = ps;
|
|
else if (vtq.Value is long pl && pl <= int.MaxValue) state.Priority = (int)pl;
|
|
state.LastUpdateUtc = now;
|
|
break;
|
|
case AlarmField.DescAttrName:
|
|
state.DescAttrName = vtq.Value as string;
|
|
state.LastUpdateUtc = now;
|
|
break;
|
|
case AlarmField.Acked:
|
|
{
|
|
var wasAcked = state.Acked;
|
|
var isAcked = vtq.Value is bool b && b;
|
|
state.Acked = isAcked;
|
|
state.LastUpdateUtc = now;
|
|
// Fire Acknowledged only when transitioning false→true. Don't fire on initial
|
|
// subscribe callback (wasAcked==isAcked in that case because the state starts
|
|
// with Acked=false and the initial probe is usually true for an un-active alarm).
|
|
if (!wasAcked && isAcked && state.InAlarm)
|
|
{
|
|
state.LastTransitionUtc = now;
|
|
transition = new AlarmTransition(state.AlarmTag, AlarmStateTransition.Acknowledged, state.Priority, state.DescAttrName, now);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (transition is { } t)
|
|
{
|
|
TransitionRaised?.Invoke(this, t);
|
|
}
|
|
}
|
|
|
|
public IReadOnlyList<AlarmSnapshot> SnapshotStates()
|
|
{
|
|
return _alarms.Values.Select(s =>
|
|
{
|
|
lock (s.Lock)
|
|
return new AlarmSnapshot(s.AlarmTag, s.InAlarm, s.Acked, s.Priority, s.DescAttrName);
|
|
}).ToList();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
_alarms.Clear();
|
|
_probeToAlarm.Clear();
|
|
}
|
|
|
|
private sealed class AlarmState
|
|
{
|
|
public readonly object Lock = new();
|
|
public string AlarmTag = "";
|
|
public bool InAlarm;
|
|
public bool Acked = true; // default ack'd so first false→true on subscribe doesn't misfire
|
|
public int Priority;
|
|
public string? DescAttrName;
|
|
public DateTime LastUpdateUtc;
|
|
public DateTime LastTransitionUtc;
|
|
}
|
|
|
|
private enum AlarmField { InAlarm, Priority, DescAttrName, Acked }
|
|
}
|
|
|
|
public enum AlarmStateTransition { Active, Acknowledged, Inactive }
|
|
|
|
public sealed record AlarmTransition(
|
|
string AlarmTag,
|
|
AlarmStateTransition Transition,
|
|
int Priority,
|
|
string? DescAttrName,
|
|
DateTime AtUtc);
|
|
|
|
public sealed record AlarmSnapshot(
|
|
string AlarmTag,
|
|
bool InAlarm,
|
|
bool Acked,
|
|
int Priority,
|
|
string? DescAttrName);
|