Foundational PRs from lmx_mxgw_impl.md, all green. Bodies only — DI/wiring deferred to PR 1+2.W (combined wire-up) and PR 3.W. PR 1.1 — IHistorianDataSource lifted to Core.Abstractions/Historian/ Reuses existing DataValueSnapshot + HistoricalEvent shapes; sidecar (PR 3.4) translates byte-quality → uint StatusCode internally. PR 1.2 — IHistoryRouter + HistoryRouter on the server Longest-prefix-match resolution, case-insensitive, ObjectDisposed-guarded, swallow-on-shutdown disposal of misbehaving sources. PR 1.3 — DriverNodeManager.HistoryRead* dispatch through IHistoryRouter Per-tag resolution with LegacyDriverHistoryAdapter wrapping `_driver as IHistoryProvider` so existing tests + drivers keep working until PR 7.2 retires the fallback. PR 2.1 — AlarmConditionInfo extended with five sub-attribute refs InAlarmRef / PriorityRef / DescAttrNameRef / AckedRef / AckMsgWriteRef. Optional defaulted parameters preserve all existing 3-arg call sites. PR 2.2 — AlarmConditionService state machine in Server/Alarms/ Driver-agnostic port of GalaxyAlarmTracker. Sub-attribute refs come from AlarmConditionInfo, values arrive as DataValueSnapshot, ack writes route through IAlarmAcknowledger. State machine preserves Active/Acknowledged/ Inactive transitions, Acked-on-active reset, post-disposal silence. PR 2.3 — DriverNodeManager wires AlarmConditionService MarkAsAlarmCondition registers each alarm-bearing variable with the service; DriverWritableAcknowledger routes ack-message writes through the driver's IWritable + CapabilityInvoker. Service-raised transitions route via OnAlarmServiceTransition → matching ConditionSink. Legacy IAlarmSource path unchanged for null service. PR 3.1 — Driver.Historian.Wonderware shell project (net48 x86) Console host shell + smoke test; SDK references + code lift come in PR 3.2. Tests: 9 (PR 1.1) + 5 (PR 2.1) + 10 (PR 1.2) + 19 (PR 2.2) + 1 (PR 3.1) all pass. Existing AlarmSubscribeIntegrationTests + HistoryReadIntegrationTests unchanged. Plan + audit docs (lmx_backend.md, lmx_mxgw.md, lmx_mxgw_impl.md) included so parallel subagent worktrees can read them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
290 lines
12 KiB
C#
290 lines
12 KiB
C#
using System.Collections.Concurrent;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server.Alarms;
|
|
|
|
/// <summary>
|
|
/// Server-level alarm-condition state machine. Tracks one entry per registered
|
|
/// condition; consumes value changes from the four sub-attribute references in
|
|
/// <see cref="AlarmConditionInfo"/> (InAlarm / Priority / Description / Acked) and
|
|
/// raises <see cref="TransitionRaised"/> on Active / Acknowledged / Inactive
|
|
/// transitions per OPC UA Part 9 (simplified). Operator acknowledgement routes
|
|
/// through <see cref="IAlarmAcknowledger"/> against <c>AckMsgWriteRef</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This is the driver-agnostic replacement for <c>GalaxyAlarmTracker</c>. The
|
|
/// service does not own subscription lifecycle — PR 2.3 will wire DriverNodeManager
|
|
/// to subscribe through the driver's <c>ISubscribable</c> and forward value changes
|
|
/// here via <see cref="OnValueChanged"/>. Keeping the service free of subscription
|
|
/// plumbing makes it trivially testable and lets future drivers feed it from any
|
|
/// value source (in-process, gRPC, named pipe).
|
|
/// </remarks>
|
|
public sealed class AlarmConditionService : IDisposable
|
|
{
|
|
private readonly Func<DateTime> _clock;
|
|
|
|
// ConditionId → state.
|
|
private readonly ConcurrentDictionary<string, AlarmConditionState> _conditions =
|
|
new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
// Sub-attribute full ref → (conditionId, which field). Multiple conditions may
|
|
// observe the same sub-attribute (rare but legal); the value is a list to support
|
|
// fan-out on a single value change.
|
|
private readonly ConcurrentDictionary<string, List<(string ConditionId, AlarmField Field)>> _refToCondition =
|
|
new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
private readonly object _refMapLock = new();
|
|
|
|
private bool _disposed;
|
|
|
|
/// <summary>
|
|
/// Fired when a registered condition transitions Active / Acknowledged / Inactive.
|
|
/// Handlers must be cheap; the event is raised on whatever thread feeds
|
|
/// <see cref="OnValueChanged"/> and blocks the value-change pipeline.
|
|
/// </summary>
|
|
public event EventHandler<AlarmConditionTransition>? TransitionRaised;
|
|
|
|
public AlarmConditionService() : this(() => DateTime.UtcNow) { }
|
|
|
|
/// <summary>Test seam — inject a fixed clock for deterministic transition timestamps.</summary>
|
|
internal AlarmConditionService(Func<DateTime> clock)
|
|
{
|
|
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
|
}
|
|
|
|
/// <summary>Number of currently tracked conditions. Diagnostic only.</summary>
|
|
public int TrackedCount => _conditions.Count;
|
|
|
|
/// <summary>
|
|
/// Register a condition. Idempotent — repeat calls for the same
|
|
/// <paramref name="conditionId"/> are a no-op. The acker is captured for the
|
|
/// condition's lifetime; pass null when the driver does not accept acks.
|
|
/// </summary>
|
|
public void Track(string conditionId, AlarmConditionInfo info, IAlarmAcknowledger? acker = null)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(conditionId);
|
|
ArgumentNullException.ThrowIfNull(info);
|
|
|
|
var state = new AlarmConditionState(conditionId, info, acker);
|
|
if (!_conditions.TryAdd(conditionId, state)) return;
|
|
|
|
lock (_refMapLock)
|
|
{
|
|
AddRefMapping(info.InAlarmRef, conditionId, AlarmField.InAlarm);
|
|
AddRefMapping(info.PriorityRef, conditionId, AlarmField.Priority);
|
|
AddRefMapping(info.DescAttrNameRef, conditionId, AlarmField.DescAttrName);
|
|
AddRefMapping(info.AckedRef, conditionId, AlarmField.Acked);
|
|
}
|
|
}
|
|
|
|
/// <summary>Deregister a condition. No-op when not tracked.</summary>
|
|
public void Untrack(string conditionId)
|
|
{
|
|
if (_disposed) return;
|
|
if (!_conditions.TryRemove(conditionId, out var state)) return;
|
|
|
|
lock (_refMapLock)
|
|
{
|
|
RemoveRefMapping(state.Info.InAlarmRef, conditionId);
|
|
RemoveRefMapping(state.Info.PriorityRef, conditionId);
|
|
RemoveRefMapping(state.Info.DescAttrNameRef, conditionId);
|
|
RemoveRefMapping(state.Info.AckedRef, conditionId);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the set of sub-attribute references the service currently needs
|
|
/// subscribed. Callers wire one subscription per ref through the driver's
|
|
/// <see cref="ISubscribable"/>; PR 2.3 owns that wiring.
|
|
/// </summary>
|
|
public IReadOnlyCollection<string> GetSubscribedReferences()
|
|
{
|
|
lock (_refMapLock) return [.. _refToCondition.Keys];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Operator acknowledgement entry point. Returns false when the condition is
|
|
/// not tracked, the condition has no acker registered, the condition has no
|
|
/// <c>AckMsgWriteRef</c>, or the acker reports the write failed.
|
|
/// </summary>
|
|
public Task<bool> AcknowledgeAsync(string conditionId, string comment, CancellationToken cancellationToken = default)
|
|
{
|
|
if (_disposed || !_conditions.TryGetValue(conditionId, out var state))
|
|
return Task.FromResult(false);
|
|
if (state.Acker is null || string.IsNullOrEmpty(state.Info.AckMsgWriteRef))
|
|
return Task.FromResult(false);
|
|
return state.Acker.WriteAckMessageAsync(state.Info.AckMsgWriteRef, comment ?? string.Empty, cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Snapshot every tracked condition's current state. Diagnostic / dashboard use only.
|
|
/// </summary>
|
|
public IReadOnlyList<AlarmConditionSnapshot> Snapshot()
|
|
{
|
|
return [.. _conditions.Values.Select(s =>
|
|
{
|
|
lock (s.Lock)
|
|
return new AlarmConditionSnapshot(s.ConditionId, s.InAlarm, s.Acked, s.Priority, s.Description);
|
|
})];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Feed a value change for one of the registered sub-attribute references.
|
|
/// The service runs the state machine and raises <see cref="TransitionRaised"/>
|
|
/// when the change produces a lifecycle transition. Unknown references are
|
|
/// silently dropped — the caller may register and unregister concurrently with
|
|
/// value-change delivery, and a stale callback for a recently-untracked
|
|
/// condition must not throw.
|
|
/// </summary>
|
|
public void OnValueChanged(string fullReference, DataValueSnapshot value)
|
|
{
|
|
if (_disposed) return;
|
|
if (string.IsNullOrEmpty(fullReference)) return;
|
|
|
|
List<(string ConditionId, AlarmField Field)>? targets;
|
|
lock (_refMapLock)
|
|
{
|
|
if (!_refToCondition.TryGetValue(fullReference, out targets) || targets.Count == 0) return;
|
|
// Snapshot under lock; the state machine runs outside.
|
|
targets = [.. targets];
|
|
}
|
|
|
|
var now = _clock();
|
|
foreach (var (conditionId, field) in targets)
|
|
{
|
|
if (!_conditions.TryGetValue(conditionId, out var state)) continue;
|
|
|
|
AlarmConditionTransition? transition = null;
|
|
lock (state.Lock)
|
|
{
|
|
transition = ApplyValue(state, field, value, now);
|
|
}
|
|
|
|
if (transition is { } t)
|
|
{
|
|
TransitionRaised?.Invoke(this, t);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Apply one value change to one condition. Returns a transition when the
|
|
/// change crosses a state boundary; null otherwise. Caller holds <c>state.Lock</c>.
|
|
/// </summary>
|
|
private static AlarmConditionTransition? ApplyValue(
|
|
AlarmConditionState state, AlarmField field, DataValueSnapshot value, DateTime now)
|
|
{
|
|
AlarmConditionTransition? transition = null;
|
|
state.LastUpdateUtc = now;
|
|
|
|
switch (field)
|
|
{
|
|
case AlarmField.InAlarm:
|
|
{
|
|
var wasActive = state.InAlarm;
|
|
var isActive = value.Value is bool b && b;
|
|
state.InAlarm = isActive;
|
|
if (!wasActive && isActive)
|
|
{
|
|
// Reset Acked on every active transition so a re-alarm requires fresh ack.
|
|
state.Acked = false;
|
|
transition = new AlarmConditionTransition(
|
|
state.ConditionId, AlarmStateTransition.Active,
|
|
state.Priority, state.Description, now);
|
|
}
|
|
else if (wasActive && !isActive)
|
|
{
|
|
transition = new AlarmConditionTransition(
|
|
state.ConditionId, AlarmStateTransition.Inactive,
|
|
state.Priority, state.Description, now);
|
|
}
|
|
break;
|
|
}
|
|
case AlarmField.Priority:
|
|
state.Priority = CoercePriority(value.Value, state.Priority);
|
|
break;
|
|
case AlarmField.DescAttrName:
|
|
state.Description = value.Value as string;
|
|
break;
|
|
case AlarmField.Acked:
|
|
{
|
|
var wasAcked = state.Acked;
|
|
var isAcked = value.Value is bool b && b;
|
|
state.Acked = isAcked;
|
|
// Only fire Acknowledged on false → true while still active. The first
|
|
// post-Track callback often arrives with isAcked == wasAcked (state starts
|
|
// Acked=true so an initially-quiet alarm doesn't misfire).
|
|
if (!wasAcked && isAcked && state.InAlarm)
|
|
{
|
|
transition = new AlarmConditionTransition(
|
|
state.ConditionId, AlarmStateTransition.Acknowledged,
|
|
state.Priority, state.Description, now);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
return transition;
|
|
}
|
|
|
|
private static int CoercePriority(object? raw, int fallback) => raw switch
|
|
{
|
|
int i => i,
|
|
short s => s,
|
|
long l when l <= int.MaxValue => (int)l,
|
|
byte b => b,
|
|
ushort us => us,
|
|
uint ui when ui <= int.MaxValue => (int)ui,
|
|
_ => fallback,
|
|
};
|
|
|
|
private void AddRefMapping(string? fullRef, string conditionId, AlarmField field)
|
|
{
|
|
if (string.IsNullOrEmpty(fullRef)) return;
|
|
if (!_refToCondition.TryGetValue(fullRef, out var list))
|
|
{
|
|
list = [];
|
|
_refToCondition[fullRef] = list;
|
|
}
|
|
list.Add((conditionId, field));
|
|
}
|
|
|
|
private void RemoveRefMapping(string? fullRef, string conditionId)
|
|
{
|
|
if (string.IsNullOrEmpty(fullRef)) return;
|
|
if (!_refToCondition.TryGetValue(fullRef, out var list)) return;
|
|
list.RemoveAll(t => string.Equals(t.ConditionId, conditionId, StringComparison.OrdinalIgnoreCase));
|
|
if (list.Count == 0) _refToCondition.TryRemove(fullRef, out _);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
_conditions.Clear();
|
|
lock (_refMapLock) _refToCondition.Clear();
|
|
}
|
|
|
|
private enum AlarmField { InAlarm, Priority, DescAttrName, Acked }
|
|
|
|
/// <summary>Per-condition mutable state. Access guarded by <see cref="Lock"/>.</summary>
|
|
private sealed class AlarmConditionState(string conditionId, AlarmConditionInfo info, IAlarmAcknowledger? acker)
|
|
{
|
|
public readonly object Lock = new();
|
|
public string ConditionId { get; } = conditionId;
|
|
public AlarmConditionInfo Info { get; } = info;
|
|
public IAlarmAcknowledger? Acker { get; } = acker;
|
|
|
|
public bool InAlarm;
|
|
|
|
// Default Acked=true so the first post-Track callback (.Acked=true on a quiet
|
|
// alarm) doesn't misfire as a transition. Active sets it back to false.
|
|
public bool Acked = true;
|
|
|
|
public int Priority;
|
|
public string? Description;
|
|
public DateTime LastUpdateUtc;
|
|
}
|
|
}
|