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}";
}