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 Dictionary _subs = new(); private readonly Lock _subsLock = new(); private long _nextId; public FocasAlarmProjection(FocasDriver driver, TimeSpan pollInterval) { _driver = driver; _pollInterval = pollInterval; } 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); lock (_subsLock) _subs[id] = sub; sub.Loop = Task.Run(() => RunPollLoopAsync(sub, cts.Token), cts.Token); return Task.FromResult(handle); } 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 { } try { await sub.Loop.ConfigureAwait(false); } catch { } 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. /// public Task AcknowledgeAsync( IReadOnlyList acknowledgements, CancellationToken cancellationToken) => Task.CompletedTask; public async ValueTask DisposeAsync() { List snap; lock (_subsLock) { snap = [.. _subs.Values]; _subs.Clear(); } foreach (var sub in snap) { try { sub.Cts.Cancel(); } catch { } try { await sub.Loop.ConfigureAwait(false); } catch { } 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. /// 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 { /* per-tick failures are non-fatal — next tick retries */ } 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. 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). /// 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) { public FocasAlarmSubscriptionHandle Handle { get; } = handle; public HashSet? DeviceFilter { get; } = deviceFilter; public CancellationTokenSource Cts { get; } = cts; public Task Loop { get; set; } = Task.CompletedTask; public Dictionary> LastByDevice { get; } = new(StringComparer.OrdinalIgnoreCase); } } /// Handle returned by . public sealed record FocasAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle { public string DiagnosticId => $"focas-alarm-sub-{Id}"; }