Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAlarmProjection.cs
T
Joseph Doherty 826312befe docs: fill missing XML docs and strip tracking IDs from src comments
Clears CommentChecker findings in src/ (3,280 -> 209, the remainder being false
positives like PLC-5/IEEE-754/M0.0) by adding missing summaries/params/returns,
normalising inheritdoc usage, and removing Gitea/review tracking IDs while keeping
the rationale they annotated. Comment-only: comment-stripped sources are identical
to the parent commit.
2026-10-15 06:14:58 -04:00

459 lines
24 KiB
C#

using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// Polls the CNC active-alarm list (<c>cnc_rdalmmsg2</c>) of every device that owns a subscribed
/// <c>ALARM:</c> raw tag and drives ONE Part 9 condition per such tag.
/// </summary>
/// <remarks>
/// <para>
/// <b>Which tags are conditions.</b> The server decides: a raw tag whose <c>TagConfig</c> carries the
/// cross-driver <c>alarm</c> object is materialised as a condition at its RawPath, and those RawPaths
/// arrive through <see cref="SubscribeAsync"/>. Every event uses the RawPath as
/// <see cref="AlarmEventArgs.ConditionId"/> — the exact key the host routes on — and carries a
/// <see cref="AlarmEventArgs.Kind"/>, without which the host's projector keeps the prior state. Before
/// alarm selectors were supported this class treated the RawPaths as device-host filters (so nothing was ever polled) and raised
/// <c>"{host}#{Type}:{Number}"</c> with no kind (so nothing would have routed anyway).
/// </para>
/// <para>
/// <b>A failed read is not an empty list.</b> <see cref="IFocasClient.ReadAlarmsAsync"/> throws on any
/// failure; the device is then skipped for that tick and every condition on it keeps its state. The
/// host already annotates the driver's conditions <c>BadNoCommunication</c> on comms loss.
/// </para>
/// <para>
/// <b>Delivery is diffed against what the server was TOLD</b> (<see cref="_told"/>, the MTConnect
/// pattern), recorded only while an <c>OnAlarmEvent</c> handler is attached. The record lives on this
/// object, which lives as long as the driver: it survives a re-subscribe with the same set (a reconnect),
/// a re-initialise and a shutdown/re-init cycle — the server's condition nodes outlive all three, and
/// re-raising there would un-acknowledge an acknowledged alarm. A changed subscribed set prunes only the
/// RawPaths that LEFT it (the server carries every survivor across the rebuild).
/// </para>
/// <para>
/// <b>One poll loop</b>, owned by the current subscription. Evaluation and delivery run under
/// <see cref="_alarmLock"/> and re-check that the subscription is still current, so a superseded loop
/// that is still finishing its last read can never emit — the duplicate-per-reconnect window the old
/// design documented is closed. The only production handler is <c>DriverInstanceActor</c>'s
/// non-blocking <c>Tell</c>.
/// </para>
/// <para><b>Read-only.</b> FOCAS has no alarm acknowledgement; the Part 9 Acknowledge is applied server-side.</para>
/// </remarks>
internal sealed class FocasAlarmProjection : IAsyncDisposable
{
private readonly FocasDriver _driver;
private readonly ILogger _logger;
/// <summary>Serializes evaluation + delivery + the told record. Never held across an await.</summary>
private readonly Lock _alarmLock = new();
/// <summary>ConditionId (RawPath) → what the server was last told. Guarded by <see cref="_alarmLock"/>.</summary>
private readonly Dictionary<string, FocasToldCondition> _told = new(StringComparer.Ordinal);
/// <summary>Per device: the alarm keys already logged as matched by no tag. Guarded by <see cref="_alarmLock"/>.</summary>
private readonly Dictionary<string, HashSet<FocasAlarmKey>> _unmatchedLogged = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Devices whose last read came back at the per-read cap. Guarded by <see cref="_alarmLock"/>.</summary>
private readonly HashSet<string> _truncationWarned = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Devices whose alarm read is currently failing → the quality that failure maps to (one Warning per episode
/// and per kind: a refusal turning into a transport fault warns again). Guarded by <see cref="_alarmLock"/>.
/// </summary>
private readonly Dictionary<string, uint> _failingDevices = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// ConditionIds this projection has annotated <c>BadNoCommunication</c> and not yet lifted. Guarded by
/// <see cref="_alarmLock"/>. Kept apart from <see cref="_told"/>: a quality annotation never touches a
/// condition's Active/Acked state.
/// </summary>
private readonly Dictionary<string, uint> _qualityBadTold = new(StringComparer.Ordinal);
/// <summary>Canonical <c>Opc.Ua.StatusCodes.Good</c>.</summary>
private const uint QualityGood = 0x00000000u;
/// <summary>Canonical <c>Opc.Ua.StatusCodes.BadNoCommunication</c> — the CNC could not be reached.</summary>
private const uint QualityBadNoCommunication = 0x80310000u;
/// <summary>
/// Whether an alarm-read failure is a CNC REFUSAL: the CNC answered with a non-OK
/// FOCAS return code, so the transport is fine and the control simply will not serve <c>cnc_rdalmmsg2</c>
/// (e.g. <c>EW_FUNC</c>, as <c>cnc_rdparam</c> does on the Makino). That is a capability answer, not comms
/// loss: it must NOT degrade driver health, or an alarm-only driver on such a control would be reconnected by
/// the comms-loss watchdog every 90 s forever. Everything else (session down, wire fault, malformed reply, backoff)
/// is a transport failure.
/// </summary>
/// <param name="error">The alarm-read failure.</param>
/// <returns><see langword="true"/> for a refusal.</returns>
internal static bool IsCncRefusal(Exception error) => error is Wire.FocasWireException { Rc: not null };
/// <summary>Serializes loop start/stop.</summary>
private readonly SemaphoreSlim _loopGate = new(1, 1);
private Subscription? _current;
private CancellationTokenSource? _loopCts;
private Task _loop = Task.CompletedTask;
private long _nextId;
/// <summary>Initializes a new FOCAS alarm projection.</summary>
/// <param name="driver">The owning driver.</param>
/// <param name="logger">Optional logger.</param>
public FocasAlarmProjection(FocasDriver driver, ILogger? logger = null)
{
_driver = driver;
_logger = logger ?? NullLogger.Instance;
}
/// <summary>Gets the live subscription, or <see langword="null"/>. Test seam.</summary>
internal Subscription? Current => Volatile.Read(ref _current);
/// <summary>
/// Replaces the live subscription with one over <paramref name="sourceNodeIds"/> (alarm-tag RawPaths) and
/// (re)starts the poll loop. Collapses to ONE subscription: the owning <c>DriverInstanceActor</c> is the
/// only caller and re-subscribes on every Connected entry.
/// </summary>
/// <param name="sourceNodeIds">The alarm-bearing raw tags' RawPaths.</param>
/// <returns>The subscription handle.</returns>
public async Task<IAlarmSubscriptionHandle> SubscribeAsync(IReadOnlyList<string> sourceNodeIds)
{
ArgumentNullException.ThrowIfNull(sourceNodeIds);
var refs = new HashSet<string>(StringComparer.Ordinal);
foreach (var r in sourceNodeIds)
if (!string.IsNullOrWhiteSpace(r)) refs.Add(r);
var sub = new Subscription(new FocasAlarmSubscriptionHandle(Interlocked.Increment(ref _nextId)), refs);
lock (_alarmLock)
{
// Forget only the conditions that LEFT the set — the SAME set (a reconnect) prunes nothing.
foreach (var gone in _told.Keys.Where(k => !refs.Contains(k)).ToList()) _told.Remove(gone);
// A (re)subscribe follows a reconnect, where the host re-annotates every condition Good; forget
// which conditions this projection marked Bad, so a still-failing device re-asserts its Bad.
_qualityBadTold.Clear();
Volatile.Write(ref _current, sub);
}
await RestartLoopAsync().ConfigureAwait(false);
return sub.Handle;
}
/// <summary>Drops the live subscription when <paramref name="handle"/> is it; a superseded handle is a no-op.</summary>
/// <param name="handle">The handle returned by <see cref="SubscribeAsync"/>.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public async Task UnsubscribeAsync(IAlarmSubscriptionHandle handle)
{
lock (_alarmLock)
{
if (Volatile.Read(ref _current) is not { } live || !ReferenceEquals(live.Handle, handle)) return;
Volatile.Write(ref _current, null);
}
await StopLoopAsync().ConfigureAwait(false);
}
/// <summary>
/// FOCAS has no ack wire call — the CNC clears alarms on its own when the underlying condition resolves.
/// The Part 9 Acknowledge is applied server-side; this is the no-op success the host routes it to, so
/// nothing is ever written to the CNC.
/// </summary>
/// <param name="acknowledgements">Alarms to acknowledge.</param>
/// <returns>A completed task.</returns>
public static Task AcknowledgeAsync(IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements)
{
ArgumentNullException.ThrowIfNull(acknowledgements);
return Task.CompletedTask;
}
/// <summary>
/// Stops the poll loop but KEEPS the subscription and the told record — the device/tag tables are about
/// to be rebuilt (re-initialise, shutdown). <see cref="ResumeAsync"/> restarts it.
/// </summary>
/// <returns>A task representing the asynchronous operation.</returns>
public Task PauseAsync() => StopLoopAsync();
/// <summary>Restarts the poll loop when a subscription is live — after the driver's tables are rebuilt.</summary>
/// <returns>A task representing the asynchronous operation.</returns>
public Task ResumeAsync() => Volatile.Read(ref _current) is null ? Task.CompletedTask : RestartLoopAsync();
/// <summary>Stops the poll loop and releases the projection.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync() => await StopLoopAsync().ConfigureAwait(false);
private async Task RestartLoopAsync()
{
await _loopGate.WaitAsync().ConfigureAwait(false);
try
{
await StopLoopCoreAsync().ConfigureAwait(false);
if (Volatile.Read(ref _current) is not { } sub) return;
var cts = new CancellationTokenSource();
_loopCts = cts;
_loop = Task.Run(() => RunPollLoopAsync(sub, cts.Token), CancellationToken.None);
}
finally { _loopGate.Release(); }
}
private async Task StopLoopAsync()
{
await _loopGate.WaitAsync().ConfigureAwait(false);
try { await StopLoopCoreAsync().ConfigureAwait(false); }
finally { _loopGate.Release(); }
}
/// <summary>Cancels and joins the loop (bounded). Caller holds <see cref="_loopGate"/>.</summary>
private async Task StopLoopCoreAsync()
{
if (_loopCts is not { } cts) return;
_loopCts = null;
try { cts.Cancel(); }
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling the FOCAS alarm poll loop failed"); }
var loop = _loop;
try
{
var finished = await Task.WhenAny(loop, Task.Delay(FocasDriver.LoopJoinTimeout)).ConfigureAwait(false);
if (finished != loop)
_logger.LogWarning("FOCAS alarm poll loop did not stop within {Timeout}; it cannot emit once superseded",
FocasDriver.LoopJoinTimeout);
}
catch (Exception ex) { _logger.LogDebug(ex, "Awaiting the FOCAS alarm poll loop failed"); }
cts.Dispose();
}
private async Task RunPollLoopAsync(Subscription sub, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
await PollOnceAsync(sub, ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch (Exception ex)
{
_logger.LogDebug(ex, "FOCAS alarm-projection poll tick failed");
}
try { await Task.Delay(_driver.AlarmPollInterval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
}
}
/// <summary>One pass: read each device that owns a subscribed alarm tag, and evaluate its conditions.</summary>
/// <param name="sub">The subscription the pass serves.</param>
/// <param name="ct">Cancellation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
internal async Task PollOnceAsync(Subscription sub, CancellationToken ct)
{
var tags = _driver.AlarmTags;
var devices = sub.References
.Select(r => tags.GetValueOrDefault(r))
.OfType<FocasAlarmTag>()
.Select(t => t.DeviceHostAddress)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var host in devices)
{
ct.ThrowIfCancellationRequested();
IReadOnlyList<FocasActiveAlarm> alarms;
try
{
alarms = await _driver.ReadDeviceAlarmsAsync(host, ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex)
{
// Not "no alarms": keep every condition on this device exactly as the server was last told, but say
// why it cannot be read. A transport failure reaches driver health (an alarm-only driver has no other
// read to trip the comms-loss watchdog with); a CNC refusal does not (R2). Polling continues at the
// normal cadence either way.
if (!IsCncRefusal(ex)) _driver.NoteAlarmReadFailed(host, ex);
else _driver.NoteAlarmReadRefused(host); // the CNC answered: the transport to it is fine
TickFailed(sub, host, tags, ex);
continue;
}
_driver.NoteAlarmReadSucceeded(host);
Tick(sub, host, alarms, tags);
}
}
/// <summary>
/// Evaluates every subscribed condition on <paramref name="deviceHostAddress"/> against one SUCCESSFUL read
/// and delivers what changed. Extracted so tests can drive a tick without the loop.
/// </summary>
/// <param name="sub">The subscription the read was taken for; a superseded one emits nothing.</param>
/// <param name="deviceHostAddress">The device read.</param>
/// <param name="alarms">Its active alarms.</param>
/// <param name="tags">The driver's alarm-tag table (RawPath → tag).</param>
internal void Tick(
Subscription sub,
string deviceHostAddress,
IReadOnlyList<FocasActiveAlarm> alarms,
IReadOnlyDictionary<string, FocasAlarmTag> tags)
{
var deviceTags = tags.Values
.Where(t => string.Equals(t.DeviceHostAddress, deviceHostAddress, StringComparison.OrdinalIgnoreCase))
.ToList();
var deviceSelectors = deviceTags.Select(t => t.Selector).ToList();
var truncated = alarms.Count >= Wire.WireFocasClient.MaxAlarmsPerRead;
var now = DateTime.UtcNow;
lock (_alarmLock)
{
if (!ReferenceEquals(Volatile.Read(ref _current), sub)) return;
NoteTruncation(deviceHostAddress, truncated, alarms.Count);
NoteUnmatched(deviceHostAddress, alarms, deviceSelectors);
// Nobody to tell (the host detached while it reconnects): record nothing, so the re-subscribe that
// follows still sees the difference and delivers it.
var handlers = _driver.AlarmHandlers;
if (handlers is null) return;
if (_failingDevices.Remove(deviceHostAddress, out _))
_logger.LogInformation("FOCAS alarm read for {Host} recovered", deviceHostAddress);
foreach (var tag in deviceTags.Where(t => sub.References.Contains(t.RawPath)))
{
// Lift a Bad this projection put there BEFORE the normal diff, so a recovery that also changed the
// alarm state (cleared during the outage) lands as Good then Clear.
if (_qualityBadTold.Remove(tag.RawPath, out _))
Deliver(handlers, QualityOnly(tag, sub.Handle, QualityGood, now), tag.RawPath);
var view = FocasAlarmMatcher.Evaluate(tag.Selector, alarms, deviceSelectors, truncated);
_told.TryGetValue(tag.RawPath, out var told);
var (evt, nowTold) = FocasAlarmConditionMapper.Transition(told, view, tag, sub.Handle, now);
_told[tag.RawPath] = nowTold;
if (evt is null) continue;
Deliver(handlers, evt, tag.RawPath);
}
}
}
/// <summary>
/// A FAILED read of <paramref name="deviceHostAddress"/>: conditions keep their state, but
/// each subscribed one is annotated once per outage through the quality-only path (no Active/Acked change,
/// no <c>/alerts</c> row) — <c>BadNoCommunication</c> for a transport failure, <c>BadNotSupported</c> for a CNC
/// refusal (R2, <see cref="IsCncRefusal"/>) — and the first failure of the episode (per kind) logs one
/// Warning. The next successful read lifts the Bad in <see cref="Tick"/>.
/// </summary>
/// <param name="sub">The subscription the read was taken for; a superseded one emits nothing.</param>
/// <param name="deviceHostAddress">The device whose read failed.</param>
/// <param name="tags">The driver's alarm-tag table (RawPath → tag).</param>
/// <param name="error">Why the read failed.</param>
internal void TickFailed(
Subscription sub, string deviceHostAddress, IReadOnlyDictionary<string, FocasAlarmTag> tags, Exception error)
{
var now = DateTime.UtcNow;
lock (_alarmLock)
{
if (!ReferenceEquals(Volatile.Read(ref _current), sub)) return;
var refusal = IsCncRefusal(error);
var quality = refusal ? FocasStatusMapper.BadNotSupported : QualityBadNoCommunication;
if (!_failingDevices.TryGetValue(deviceHostAddress, out var was) || was != quality)
{
_failingDevices[deviceHostAddress] = quality;
if (refusal)
_logger.LogWarning(error,
"FOCAS CNC {Host} refuses the alarm read (cnc_rdalmmsg2); its alarm conditions keep their state " +
"and are marked BadNotSupported. This is a capability answer, not comms loss — driver health is " +
"unaffected and polling continues", deviceHostAddress);
else
_logger.LogWarning(error,
"FOCAS alarm read (cnc_rdalmmsg2) failed for {Host}; its alarm conditions keep their state and " +
"are marked BadNoCommunication until a read succeeds", deviceHostAddress);
}
else
_logger.LogDebug(error, "FOCAS alarm read for {Host} still failing", deviceHostAddress);
// As for transitions: with nobody listening, record nothing, so the next failure tells it.
var handlers = _driver.AlarmHandlers;
if (handlers is null) return;
foreach (var tag in tags.Values.Where(t =>
string.Equals(t.DeviceHostAddress, deviceHostAddress, StringComparison.OrdinalIgnoreCase)
&& sub.References.Contains(t.RawPath)))
{
if (_qualityBadTold.TryGetValue(tag.RawPath, out var told) && told == quality) continue;
_qualityBadTold[tag.RawPath] = quality;
Deliver(handlers, QualityOnly(tag, sub.Handle, quality, now), tag.RawPath);
}
}
}
/// <summary>A quality-only annotation: <see cref="AlarmTransitionKind.Unspecified"/> + a status code.</summary>
private AlarmEventArgs QualityOnly(FocasAlarmTag tag, IAlarmSubscriptionHandle handle, uint quality, DateTime now)
{
_told.TryGetValue(tag.RawPath, out var told);
return new AlarmEventArgs(handle,
SourceNodeId: tag.DeviceHostAddress,
ConditionId: tag.RawPath,
AlarmType: told?.Winner is { } w ? FocasAlarmConditionMapper.TypeLabel(w.Type) : "CncAlarm",
Message: told?.Message ?? "",
Severity: told?.Severity ?? AlarmSeverity.Low,
SourceTimestampUtc: now,
Kind: AlarmTransitionKind.Unspecified,
OpcUaSeverity: told?.OpcUaSeverity,
QualityStatusCode: quality);
}
/// <summary>Invokes every handler, isolating a faulting one. Caller holds the lock.</summary>
private void Deliver(EventHandler<AlarmEventArgs> handlers, AlarmEventArgs evt, string conditionId)
{
foreach (var registration in handlers.GetInvocationList())
{
try { ((EventHandler<AlarmEventArgs>)registration).Invoke(_driver, evt); }
catch (Exception ex)
{
_logger.LogWarning(ex,
"FOCAS driver caught a fault from an alarm subscriber for {ConditionId}; delivery continues.",
conditionId);
}
}
}
/// <summary>Warns once (per episode) when a read came back at the per-read cap. Caller holds the lock.</summary>
private void NoteTruncation(string host, bool truncated, int count)
{
if (!truncated)
{
_truncationWarned.Remove(host);
return;
}
if (_truncationWarned.Add(host))
_logger.LogWarning(
"FOCAS device {Host} returned {Count} active alarms — the per-read cap; more may be active and unseen. " +
"ALARM:* / ALARM:OTHER conditions on it are marked '[alarm list truncated]'.", host, count);
}
/// <summary>
/// Logs (once per alarm) an active alarm that no condition on its device will show — no <c>*</c>, no
/// <c>OTHER</c> and no matching type selector. Caller holds the lock.
/// </summary>
private void NoteUnmatched(string host, IReadOnlyList<FocasActiveAlarm> alarms, IReadOnlyCollection<FocasAlarmSelector> selectors)
{
var uncovered = alarms.Where(a => !FocasAlarmMatcher.IsCovered(a, selectors))
.Select(a => new FocasAlarmKey(a.Type, a.AlarmNumber))
.ToHashSet();
if (!_unmatchedLogged.TryGetValue(host, out var logged))
_unmatchedLogged[host] = logged = [];
foreach (var key in uncovered.Where(k => !logged.Contains(k)))
_logger.LogInformation(
"FOCAS device {Host} has active alarm {Number} (type {Type}) that no ALARM: tag selects — author " +
"ALARM:OTHER or ALARM:* on the device to surface it.", host, key.Number, key.Type);
// Remember exactly the currently-uncovered set, so an alarm that clears and comes back is logged again.
_unmatchedLogged[host] = uncovered;
}
/// <summary>The live alarm subscription: its handle and the ConditionIds (RawPaths) it names.</summary>
/// <param name="Handle">The handle.</param>
/// <param name="References">The subscribed RawPaths.</param>
internal sealed record Subscription(FocasAlarmSubscriptionHandle Handle, IReadOnlySet<string> References);
}
/// <summary>Handle returned by <see cref="FocasDriver.SubscribeAlarmsAsync"/>.</summary>
public sealed record FocasAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle
{
/// <inheritdoc />
public string DiagnosticId => $"focas-alarm-sub-{Id}";
}