826312befe
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.
459 lines
24 KiB
C#
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}";
|
|
}
|