v2 mxgw migration — Phase 1+2+3.1 wiring (7 PRs)
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>
This commit is contained in:
289
src/ZB.MOM.WW.OtOpcUa.Server/Alarms/AlarmConditionService.cs
Normal file
289
src/ZB.MOM.WW.OtOpcUa.Server/Alarms/AlarmConditionService.cs
Normal file
@@ -0,0 +1,289 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Alarms;
|
||||
|
||||
/// <summary>
|
||||
/// Lifecycle transition for an alarm condition. Mirrors OPC UA Part 9 alarm states
|
||||
/// simplified to the active / acknowledged / inactive triplet that every driver in
|
||||
/// the repo exposes today.
|
||||
/// </summary>
|
||||
public enum AlarmStateTransition
|
||||
{
|
||||
/// <summary>InAlarm flipped false → true. Default to unacknowledged.</summary>
|
||||
Active,
|
||||
|
||||
/// <summary>Acked flipped false → true while the alarm is still active.</summary>
|
||||
Acknowledged,
|
||||
|
||||
/// <summary>InAlarm flipped true → false.</summary>
|
||||
Inactive,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One alarm-state transition raised by <see cref="AlarmConditionService.TransitionRaised"/>.
|
||||
/// </summary>
|
||||
/// <param name="ConditionId">Stable identifier the caller registered the condition under (typically the driver's alarm full reference).</param>
|
||||
/// <param name="Transition">Which state the alarm transitioned to.</param>
|
||||
/// <param name="Priority">Latest known priority. 0 when no priority sub-attribute was registered or no value has been observed yet.</param>
|
||||
/// <param name="Description">Latest known description text; null when not registered or not yet observed.</param>
|
||||
/// <param name="AtUtc">Server-clock UTC of the value change that produced this transition.</param>
|
||||
public sealed record AlarmConditionTransition(
|
||||
string ConditionId,
|
||||
AlarmStateTransition Transition,
|
||||
int Priority,
|
||||
string? Description,
|
||||
DateTime AtUtc);
|
||||
|
||||
/// <summary>
|
||||
/// Read-only snapshot of an alarm condition's current state. Used for diagnostics
|
||||
/// and dashboards; not part of the live transition stream.
|
||||
/// </summary>
|
||||
public sealed record AlarmConditionSnapshot(
|
||||
string ConditionId,
|
||||
bool InAlarm,
|
||||
bool Acked,
|
||||
int Priority,
|
||||
string? Description);
|
||||
23
src/ZB.MOM.WW.OtOpcUa.Server/Alarms/IAlarmAcknowledger.cs
Normal file
23
src/ZB.MOM.WW.OtOpcUa.Server/Alarms/IAlarmAcknowledger.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Alarms;
|
||||
|
||||
/// <summary>
|
||||
/// Strategy for routing operator acknowledgement writes back to the underlying driver.
|
||||
/// Decouples <see cref="AlarmConditionService"/> from any specific driver's write API
|
||||
/// so the service can be tested without a real driver and reused across drivers with
|
||||
/// different write paths.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// PR 2.3 supplies a default implementation that writes through the driver's
|
||||
/// <c>IWritable.WriteAsync</c> using the <c>AckMsgWriteRef</c> from
|
||||
/// <c>AlarmConditionInfo</c>. Drivers that route acks differently (e.g. a dedicated
|
||||
/// RPC) can supply a custom implementation when registering the condition.
|
||||
/// </remarks>
|
||||
public interface IAlarmAcknowledger
|
||||
{
|
||||
/// <summary>
|
||||
/// Writes the operator's <paramref name="comment"/> to <paramref name="ackMsgWriteRef"/>.
|
||||
/// Returns true on driver-reported success, false otherwise. Implementations should
|
||||
/// propagate cancellation but never throw on a write that the driver cleanly rejects.
|
||||
/// </summary>
|
||||
Task<bool> WriteAckMessageAsync(string ackMsgWriteRef, string comment, CancellationToken cancellationToken);
|
||||
}
|
||||
Reference in New Issue
Block a user