using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; /// /// Task #177 — projects AB Logix ALMD alarm instructions onto the OPC UA alarm surface by /// polling the ALMD UDT's InFaulted / Acked / Severity members at a /// configurable interval + translating state transitions into OnAlarmEvent /// callbacks on the owning . Feature-flagged off by default via /// ; callers that leave the flag off /// get a no-op subscribe path so capability negotiation still works. /// /// /// ALMD-only in this pass. ALMA (analog alarm) projection is a follow-up because /// its threshold + limit semantics need more design — ALMD's "is the alarm active + has /// the operator acked" shape maps cleanly onto the driver-agnostic /// contract without concessions. /// /// Polling reuses , so ALMD reads get the #194 /// whole-UDT optimization for free when the ALMD is declared with its standard members. /// One poll loop per subscription call; the loop batches every /// member read across the full source-node set into a single ReadAsync per tick. /// /// ALMD Acked write semantics on Logix are rising-edge sensitive at the /// instruction level — writing Acked=1 directly is honored by FT View + the /// standard HMI templates, but some PLC programs read AckCmd + look for the edge /// themselves. We pick the simpler Acked write for first pass; operators whose /// ladder watches AckCmd can wire a follow-up "AckCmd 0→1→0" pulse on the client /// side until a driver-level knob lands. /// internal sealed class AbCipAlarmProjection : IAsyncDisposable { private readonly AbCipDriver _driver; private readonly TimeSpan _pollInterval; private readonly ILogger _logger; private readonly Dictionary _subs = new(); private readonly Lock _subsLock = new(); private long _nextId; /// Initializes a new instance of the class. /// The AB CIP driver instance. /// The interval at which to poll for alarm state changes. /// Optional logger instance. public AbCipAlarmProjection(AbCipDriver driver, TimeSpan pollInterval, ILogger? logger = null) { _driver = driver; _pollInterval = pollInterval; _logger = logger ?? NullLogger.Instance; } /// Subscribes to alarm events for the specified source nodes. /// The node identifiers to monitor for alarm state changes. /// A cancellation token to stop the operation. /// A subscription handle for managing the subscription. public async Task SubscribeAsync( IReadOnlyList sourceNodeIds, CancellationToken cancellationToken) { var id = Interlocked.Increment(ref _nextId); var handle = new AbCipAlarmSubscriptionHandle(id); var cts = new CancellationTokenSource(); var sub = new Subscription(handle, [..sourceNodeIds], cts); lock (_subsLock) _subs[id] = sub; sub.Loop = Task.Run(() => RunPollLoopAsync(sub, cts.Token), cts.Token); await Task.CompletedTask; return handle; } /// Unsubscribes from alarm events using the provided subscription handle. /// The subscription handle obtained from . /// A cancellation token to stop the operation. /// A task representing the asynchronous unsubscribe operation. public async Task UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) { if (handle is not AbCipAlarmSubscriptionHandle h) return; Subscription? sub; lock (_subsLock) { if (!_subs.Remove(h.Id, out sub)) return; } try { sub.Cts.Cancel(); } catch { } try { await sub.Loop.ConfigureAwait(false); } catch { } sub.Cts.Dispose(); } /// Acknowledges one or more active alarms. /// The list of acknowledgement requests specifying which alarms to acknowledge. /// A cancellation token to stop the operation. /// A task representing the asynchronous acknowledgement operation. public async Task AcknowledgeAsync( IReadOnlyList acknowledgements, CancellationToken cancellationToken) { if (acknowledgements.Count == 0) return; // Write Acked=1 per request. IWritable isn't on AbCipAlarmProjection so route through // the driver's public interface — delegating instead of re-implementing the write path // keeps the bit-in-DINT + idempotency + per-call-host-resolve knobs intact. var requests = acknowledgements .Select(a => new WriteRequest($"{a.SourceNodeId}.Acked", true)) .ToArray(); // Best-effort — the driver's WriteAsync returns per-item status; individual ack // failures don't poison the batch. Swallow the return so a single faulted ack // doesn't bubble out of the caller's batch expectation. _ = await _driver.WriteAsync(requests, cancellationToken).ConfigureAwait(false); } /// Releases all resources associated with this alarm projection. /// A task representing the asynchronous disposal operation. public async ValueTask DisposeAsync() { List snap; lock (_subsLock) { snap = _subs.Values.ToList(); _subs.Clear(); } foreach (var sub in snap) { try { sub.Cts.Cancel(); } catch { } try { await sub.Loop.ConfigureAwait(false); } catch { } sub.Cts.Dispose(); } } /// /// Poll-tick body — reads InFaulted + Severity for every source node id /// in the subscription, diffs each against last-seen state, fires raise/clear events. /// Extracted so tests can drive one tick without standing up the Task.Run loop. /// /// The subscription to process. /// The data values read from the subscription source nodes. internal void Tick(Subscription sub, IReadOnlyList results) { // results index layout: for each sourceNode, [InFaulted, Severity] in order. for (var i = 0; i < sub.SourceNodeIds.Count; i++) { var nodeId = sub.SourceNodeIds[i]; var inFaultedDv = results[i * 2]; var severityDv = results[i * 2 + 1]; if (inFaultedDv.StatusCode != AbCipStatusMapper.Good) continue; var nowFaulted = ToBool(inFaultedDv.Value); var severity = ToInt(severityDv.Value); var wasFaulted = sub.LastInFaulted.GetValueOrDefault(nodeId, false); sub.LastInFaulted[nodeId] = nowFaulted; if (!wasFaulted && nowFaulted) { _driver.InvokeAlarmEvent(new AlarmEventArgs( sub.Handle, nodeId, ConditionId: $"{nodeId}#active", AlarmType: "ALMD", Message: $"ALMD {nodeId} raised", Severity: MapSeverity(severity), SourceTimestampUtc: DateTime.UtcNow)); } else if (wasFaulted && !nowFaulted) { _driver.InvokeAlarmEvent(new AlarmEventArgs( sub.Handle, nodeId, ConditionId: $"{nodeId}#active", AlarmType: "ALMD", Message: $"ALMD {nodeId} cleared", Severity: MapSeverity(severity), SourceTimestampUtc: DateTime.UtcNow)); } } } private async Task RunPollLoopAsync(Subscription sub, CancellationToken ct) { var refs = new List(sub.SourceNodeIds.Count * 2); foreach (var nodeId in sub.SourceNodeIds) { refs.Add($"{nodeId}.InFaulted"); refs.Add($"{nodeId}.Severity"); } while (!ct.IsCancellationRequested) { try { var results = await _driver.ReadAsync(refs, ct).ConfigureAwait(false); Tick(sub, results); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch (Exception ex) { // Per-tick failures are non-fatal; next tick retries. Log at debug because a // wedged controller produces one exception per tick and the operator already // sees the failed-read warning from ReadAsync below this layer; this log just // confirms the alarm projection loop is still running. _logger.LogDebug(ex, "AbCip alarm-projection poll tick failed (will retry)"); } try { await Task.Delay(_pollInterval, ct).ConfigureAwait(false); } catch (OperationCanceledException) { break; } } } /// Maps a raw severity value to an enum value. /// The raw severity value from the alarm data. /// The corresponding alarm severity level. internal static AlarmSeverity MapSeverity(int raw) => raw switch { <= 250 => AlarmSeverity.Low, <= 500 => AlarmSeverity.Medium, <= 750 => AlarmSeverity.High, _ => AlarmSeverity.Critical, }; private static bool ToBool(object? v) => v switch { bool b => b, int i => i != 0, long l => l != 0, _ => false, }; private static int ToInt(object? v) => v switch { int i => i, long l => (int)l, short s => s, byte b => b, _ => 0, }; internal sealed class Subscription { /// Initializes a new instance of the class. /// The subscription handle. /// The source node identifiers to monitor. /// The cancellation token source for stopping the subscription. public Subscription(AbCipAlarmSubscriptionHandle handle, IReadOnlyList sourceNodeIds, CancellationTokenSource cts) { Handle = handle; SourceNodeIds = sourceNodeIds; Cts = cts; } /// Gets the subscription handle. public AbCipAlarmSubscriptionHandle Handle { get; } /// Gets the source node identifiers being monitored. public IReadOnlyList SourceNodeIds { get; } /// Gets the cancellation token source for this subscription. public CancellationTokenSource Cts { get; } /// Gets or sets the polling loop task. public Task Loop { get; set; } = Task.CompletedTask; /// Gets the dictionary tracking the last known InFaulted state for each node. public Dictionary LastInFaulted { get; } = new(StringComparer.Ordinal); } } /// Handle returned by . public sealed record AbCipAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle { /// Gets a diagnostic identifier for this subscription. public string DiagnosticId => $"abcip-alarm-sub-{Id}"; } /// /// Detects the ALMD / ALMA signature in an 's declared /// members. Used by both discovery (to stamp IsAlarm=true on the emitted /// variable) + initial driver setup (to decide which tags the alarm projection owns). /// public static class AbCipAlarmDetector { /// /// true when is a Structure whose declared members match /// the ALMD signature (InFaulted + Acked present). ALMA detection /// (analog alarms with HHLimit/HLimit/LLimit/LLLimit) /// ships as a follow-up. /// /// The tag definition to check for ALMD signature. /// True if the tag has the ALMD alarm signature; false otherwise. public static bool IsAlmd(AbCipTagDefinition tag) { if (tag.DataType != AbCipDataType.Structure || tag.Members is null) return false; var names = tag.Members.Select(m => m.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); return names.Contains("InFaulted") && names.Contains("Acked"); } }