using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; /// /// Polls each device's CNC active-alarm list via /// on a timer and translates raise / clear transitions into /// events on the owning . One poll loop per subscription; the /// loop fans out across every configured device and diffs the (AlarmNumber, /// Type) keyed active-alarm set between ticks. /// /// /// FOCAS alarms are flat per session — the CNC exposes a single active-alarm list via /// cnc_rdalmmsg2, not per-node structures the way Galaxy / AbCip ALMD do. So the /// projection ignores sourceNodeIds at the member level: every alarm event is /// raised with SourceNodeId=device-host-address. Callers that want per-device /// filtering can pass the specific host addresses as sourceNodeIds and the /// projection will skip devices not listed. /// internal sealed class FocasAlarmProjection : IAsyncDisposable { private readonly FocasDriver _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 FOCAS alarm projection. /// FOCAS driver instance. /// Polling interval. /// Optional logger. public FocasAlarmProjection(FocasDriver driver, TimeSpan pollInterval, ILogger? logger = null) { _driver = driver; _pollInterval = pollInterval; _logger = logger ?? NullLogger.Instance; } /// Subscribes to alarms from the specified device sources. /// Source node IDs to listen to. /// Cancellation token. /// A task returning the alarm subscription handle. public Task SubscribeAsync( IReadOnlyList sourceNodeIds, CancellationToken cancellationToken) { var id = Interlocked.Increment(ref _nextId); var handle = new FocasAlarmSubscriptionHandle(id); var cts = new CancellationTokenSource(); // Empty filter = listen to every configured device. Otherwise only devices whose // host address appears in sourceNodeIds are polled. var filter = sourceNodeIds.Count == 0 ? null : new HashSet(sourceNodeIds, StringComparer.OrdinalIgnoreCase); var sub = new Subscription(handle, filter, cts); // Collapse to a SINGLE active poll loop. The owning DriverInstanceActor re-subscribes // alarms on every Connected entry (its DetachAlarmSource nulls the cached handle on // Connected→Reconnecting WITHOUT calling Unsubscribe), and this projection is reused across // every in-place reconnect, so each SubscribeAsync would otherwise leak a live, never- // cancelled poll loop. There is exactly one consumer per driver instance, so retiring all // prior subscriptions before starting the new one is semantically faithful. Snapshotting + // clearing under the same lock DisposeAsync uses guarantees the stale subs can never be // double-owned (and thus never double-disposed) by a racing dispose. List stale; lock (_subsLock) { stale = [.. _subs.Values]; _subs.Clear(); _subs[id] = sub; } sub.Loop = Task.Run(() => RunPollLoopAsync(sub, cts.Token), cts.Token); // Retire superseded loops out-of-band so the new subscription's return isn't blocked on a // full poll interval (awaiting a loop means waiting for it to exit its Task.Delay). The // loops already catch internally; RetireAsync still wraps every await defensively. foreach (var old in stale) _ = RetireAsync(old); return Task.FromResult(handle); } /// /// Cancels a superseded subscription's poll loop, waits for it to wind down, and disposes /// its CTS. Fire-and-forget from ; every step is wrapped so an /// unobserved exception can never escape (the loops already swallow their own). /// /// This is NOT a zero-overlap guarantee: the retired loop keeps running until its in-flight /// read + tick complete and it observes cancellation at the next Task.Delay. If a device /// transition lands in that one-tick window, both the old and new loops can fire it once — /// i.e. at most ONE duplicate raise/clear per reconnect, transient and self-correcting (the old /// loop then exits and the new loop owns the only state). Upstream Part 9 conditions dedupe on /// ConditionId, so this is absorbed. Awaiting the retire before starting the new loop /// would close the window at the cost of blocking the subscribe for one read latency. /// /// /// The retired subscription whose loop must be cancelled + disposed. private async Task RetireAsync(Subscription sub) { try { sub.Cts.Cancel(); } catch (Exception ex) { _logger.LogDebug(ex, "Cancelling superseded alarm-subscription CTS failed"); } try { await sub.Loop.ConfigureAwait(false); } catch (Exception ex) { _logger.LogDebug(ex, "Awaiting superseded alarm-subscription loop failed during retire"); } try { sub.Cts.Dispose(); } catch (Exception ex) { _logger.LogDebug(ex, "Disposing superseded alarm-subscription CTS failed"); } } /// Unsubscribes from an alarm subscription. /// Alarm subscription handle. /// Cancellation token. /// A task representing the asynchronous operation. public async Task UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) { if (handle is not FocasAlarmSubscriptionHandle h) return; Subscription? sub; lock (_subsLock) { if (!_subs.Remove(h.Id, out sub)) return; } try { sub.Cts.Cancel(); } catch (Exception ex) { _logger.LogDebug(ex, "Cancelling alarm-subscription CTS failed"); } try { await sub.Loop.ConfigureAwait(false); } catch (Exception ex) { _logger.LogDebug(ex, "Awaiting alarm-subscription loop failed during unsubscribe"); } sub.Cts.Dispose(); } /// /// FOCAS has no ack wire call — the CNC clears alarms on its own when the underlying /// condition resolves. Swallow the request so capability negotiation succeeds, rather /// than surfacing a confusing "not supported" error to the operator. /// /// Alarms to acknowledge. /// Cancellation token. /// A completed task. public Task AcknowledgeAsync( IReadOnlyList acknowledgements, CancellationToken cancellationToken) => Task.CompletedTask; /// Disposes the alarm projection. /// A task representing the asynchronous operation. public async ValueTask DisposeAsync() { List snap; lock (_subsLock) { snap = [.. _subs.Values]; _subs.Clear(); } foreach (var sub in snap) { try { sub.Cts.Cancel(); } catch (Exception ex) { _logger.LogDebug(ex, "Cancelling alarm-subscription CTS during dispose failed"); } try { await sub.Loop.ConfigureAwait(false); } catch (Exception ex) { _logger.LogDebug(ex, "Awaiting alarm-subscription loop during dispose failed"); } sub.Cts.Dispose(); } } /// /// One poll-tick for one device. Diffs the new alarm list against the previous snapshot, /// emits raise + clear events. Extracted so tests can drive a tick without spinning up /// the full Task.Run loop. /// /// Active subscription. /// Device host address. /// Current alarms from the device. internal void Tick(Subscription sub, string deviceHostAddress, IReadOnlyList current) { var prev = sub.LastByDevice.GetValueOrDefault(deviceHostAddress) ?? []; var nowKeys = current.Select(a => AlarmKey(a)).ToHashSet(); var prevKeys = prev.Select(a => AlarmKey(a)).ToHashSet(); foreach (var a in current) { if (prevKeys.Contains(AlarmKey(a))) continue; _driver.InvokeAlarmEvent(new AlarmEventArgs( sub.Handle, SourceNodeId: deviceHostAddress, ConditionId: $"{deviceHostAddress}#{AlarmKey(a)}", AlarmType: MapAlarmType(a.Type), Message: a.Message, Severity: MapSeverity(a.Type), SourceTimestampUtc: DateTime.UtcNow)); } foreach (var a in prev) { if (nowKeys.Contains(AlarmKey(a))) continue; _driver.InvokeAlarmEvent(new AlarmEventArgs( sub.Handle, SourceNodeId: deviceHostAddress, ConditionId: $"{deviceHostAddress}#{AlarmKey(a)}", AlarmType: MapAlarmType(a.Type), Message: $"{a.Message} (cleared)", Severity: MapSeverity(a.Type), SourceTimestampUtc: DateTime.UtcNow)); } sub.LastByDevice[deviceHostAddress] = [.. current]; } private async Task RunPollLoopAsync(Subscription sub, CancellationToken ct) { while (!ct.IsCancellationRequested) { try { foreach (var (host, alarms) in await _driver.ReadActiveAlarmsAcrossDevicesAsync(sub.DeviceFilter, ct).ConfigureAwait(false)) { Tick(sub, host, alarms); } } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch (Exception ex) { /* per-tick failures are non-fatal — next tick retries */ _logger.LogDebug(ex, "FOCAS alarm-projection poll tick failed"); } try { await Task.Delay(_pollInterval, ct).ConfigureAwait(false); } catch (OperationCanceledException) { break; } } } private static string AlarmKey(FocasActiveAlarm a) => $"{a.Type}:{a.AlarmNumber}"; /// Map FOCAS type to a human-readable category; falls back to the numeric type. /// FOCAS alarm type. /// The mapped alarm type string. internal static string MapAlarmType(short type) => type switch { FocasAlarmType.Parameter => "Parameter", FocasAlarmType.PulseCode => "PulseCode", FocasAlarmType.Overtravel => "Overtravel", FocasAlarmType.Overheat => "Overheat", FocasAlarmType.Servo => "Servo", FocasAlarmType.DataIo => "DataIo", FocasAlarmType.MemoryCheck => "MemoryCheck", FocasAlarmType.MacroAlarm => "MacroAlarm", _ => $"Type{type}", }; /// /// Project FOCAS alarm types into the driver-agnostic 4-band severity. Overtravel / /// Servo / Emergency-equivalents are Critical; Parameter + Macro are Medium; rest land /// at High (everything else on a CNC is safety-relevant). /// /// FOCAS alarm type. /// The mapped alarm severity. internal static AlarmSeverity MapSeverity(short type) => type switch { FocasAlarmType.Overtravel => AlarmSeverity.Critical, FocasAlarmType.Servo => AlarmSeverity.Critical, FocasAlarmType.PulseCode => AlarmSeverity.Critical, FocasAlarmType.Parameter => AlarmSeverity.Medium, FocasAlarmType.MacroAlarm => AlarmSeverity.Medium, _ => AlarmSeverity.High, }; internal sealed class Subscription( FocasAlarmSubscriptionHandle handle, HashSet? deviceFilter, CancellationTokenSource cts) { /// Gets the subscription handle. public FocasAlarmSubscriptionHandle Handle { get; } = handle; /// Gets the device filter. public HashSet? DeviceFilter { get; } = deviceFilter; /// Gets the cancellation token source. public CancellationTokenSource Cts { get; } = cts; /// Gets or sets the polling loop task. public Task Loop { get; set; } = Task.CompletedTask; /// Gets the last seen alarms by device. public Dictionary> LastByDevice { get; } = new(StringComparer.OrdinalIgnoreCase); } } /// Handle returned by . public sealed record FocasAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle { /// public string DiagnosticId => $"focas-alarm-sub-{Id}"; }