64e3fbe035
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Adds <summary>, <param>, <typeparam>, and <inheritdoc/> tags to public members surfaced by commentchecker — resolves 5,847 of 5,869 issues (99.6%) across three /fixdocs passes.
284 lines
13 KiB
C#
284 lines
13 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
|
|
|
/// <summary>
|
|
/// Task #177 — projects AB Logix ALMD alarm instructions onto the OPC UA alarm surface by
|
|
/// polling the ALMD UDT's <c>InFaulted</c> / <c>Acked</c> / <c>Severity</c> members at a
|
|
/// configurable interval + translating state transitions into <c>OnAlarmEvent</c>
|
|
/// callbacks on the owning <see cref="AbCipDriver"/>. Feature-flagged off by default via
|
|
/// <see cref="AbCipDriverOptions.EnableAlarmProjection"/>; callers that leave the flag off
|
|
/// get a no-op subscribe path so capability negotiation still works.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>ALMD-only in this pass. ALMA (analog alarm) projection is a follow-up because
|
|
/// its threshold + limit semantics need more design — ALMD's "is the alarm active + has
|
|
/// the operator acked" shape maps cleanly onto the driver-agnostic
|
|
/// <see cref="IAlarmSource"/> contract without concessions.</para>
|
|
///
|
|
/// <para>Polling reuses <see cref="AbCipDriver.ReadAsync"/>, so ALMD reads get the #194
|
|
/// whole-UDT optimization for free when the ALMD is declared with its standard members.
|
|
/// One poll loop per subscription call; the loop batches every
|
|
/// member read across the full source-node set into a single ReadAsync per tick.</para>
|
|
///
|
|
/// <para>ALMD <c>Acked</c> write semantics on Logix are rising-edge sensitive at the
|
|
/// instruction level — writing <c>Acked=1</c> directly is honored by FT View + the
|
|
/// standard HMI templates, but some PLC programs read <c>AckCmd</c> + look for the edge
|
|
/// themselves. We pick the simpler <c>Acked</c> write for first pass; operators whose
|
|
/// ladder watches <c>AckCmd</c> can wire a follow-up "AckCmd 0→1→0" pulse on the client
|
|
/// side until a driver-level knob lands.</para>
|
|
/// </remarks>
|
|
internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
|
{
|
|
private readonly AbCipDriver _driver;
|
|
private readonly TimeSpan _pollInterval;
|
|
private readonly ILogger _logger;
|
|
private readonly Dictionary<long, Subscription> _subs = new();
|
|
private readonly Lock _subsLock = new();
|
|
private long _nextId;
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="AbCipAlarmProjection"/> class.</summary>
|
|
/// <param name="driver">The AB CIP driver instance.</param>
|
|
/// <param name="pollInterval">The interval at which to poll for alarm state changes.</param>
|
|
/// <param name="logger">Optional logger instance.</param>
|
|
public AbCipAlarmProjection(AbCipDriver driver, TimeSpan pollInterval, ILogger? logger = null)
|
|
{
|
|
_driver = driver;
|
|
_pollInterval = pollInterval;
|
|
_logger = logger ?? NullLogger.Instance;
|
|
}
|
|
|
|
/// <summary>Subscribes to alarm events for the specified source nodes.</summary>
|
|
/// <param name="sourceNodeIds">The node identifiers to monitor for alarm state changes.</param>
|
|
/// <param name="cancellationToken">A cancellation token to stop the operation.</param>
|
|
/// <returns>A subscription handle for managing the subscription.</returns>
|
|
public async Task<IAlarmSubscriptionHandle> SubscribeAsync(
|
|
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
|
{
|
|
var id = Interlocked.Increment(ref _nextId);
|
|
var handle = new AbCipAlarmSubscriptionHandle(id);
|
|
var cts = new CancellationTokenSource();
|
|
var sub = new Subscription(handle, [..sourceNodeIds], cts);
|
|
|
|
lock (_subsLock) _subs[id] = sub;
|
|
|
|
sub.Loop = Task.Run(() => RunPollLoopAsync(sub, cts.Token), cts.Token);
|
|
await Task.CompletedTask;
|
|
return handle;
|
|
}
|
|
|
|
/// <summary>Unsubscribes from alarm events using the provided subscription handle.</summary>
|
|
/// <param name="handle">The subscription handle obtained from <see cref="SubscribeAsync"/>.</param>
|
|
/// <param name="cancellationToken">A cancellation token to stop the operation.</param>
|
|
/// <returns>A task representing the asynchronous unsubscribe operation.</returns>
|
|
public async Task UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
|
{
|
|
if (handle is not AbCipAlarmSubscriptionHandle 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();
|
|
}
|
|
|
|
/// <summary>Acknowledges one or more active alarms.</summary>
|
|
/// <param name="acknowledgements">The list of acknowledgement requests specifying which alarms to acknowledge.</param>
|
|
/// <param name="cancellationToken">A cancellation token to stop the operation.</param>
|
|
/// <returns>A task representing the asynchronous acknowledgement operation.</returns>
|
|
public async Task AcknowledgeAsync(
|
|
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
|
{
|
|
if (acknowledgements.Count == 0) return;
|
|
|
|
// Write Acked=1 per request. IWritable isn't on AbCipAlarmProjection so route through
|
|
// the driver's public interface — delegating instead of re-implementing the write path
|
|
// keeps the bit-in-DINT + idempotency + per-call-host-resolve knobs intact.
|
|
var requests = acknowledgements
|
|
.Select(a => new WriteRequest($"{a.SourceNodeId}.Acked", true))
|
|
.ToArray();
|
|
// Best-effort — the driver's WriteAsync returns per-item status; individual ack
|
|
// failures don't poison the batch. Swallow the return so a single faulted ack
|
|
// doesn't bubble out of the caller's batch expectation.
|
|
_ = await _driver.WriteAsync(requests, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>Releases all resources associated with this alarm projection.</summary>
|
|
/// <returns>A task representing the asynchronous disposal operation.</returns>
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
List<Subscription> snap;
|
|
lock (_subsLock) { snap = _subs.Values.ToList(); _subs.Clear(); }
|
|
foreach (var sub in snap)
|
|
{
|
|
try { sub.Cts.Cancel(); } catch { }
|
|
try { await sub.Loop.ConfigureAwait(false); } catch { }
|
|
sub.Cts.Dispose();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Poll-tick body — reads <c>InFaulted</c> + <c>Severity</c> for every source node id
|
|
/// in the subscription, diffs each against last-seen state, fires raise/clear events.
|
|
/// Extracted so tests can drive one tick without standing up the Task.Run loop.
|
|
/// </summary>
|
|
/// <param name="sub">The subscription to process.</param>
|
|
/// <param name="results">The data values read from the subscription source nodes.</param>
|
|
internal void Tick(Subscription sub, IReadOnlyList<DataValueSnapshot> results)
|
|
{
|
|
// results index layout: for each sourceNode, [InFaulted, Severity] in order.
|
|
for (var i = 0; i < sub.SourceNodeIds.Count; i++)
|
|
{
|
|
var nodeId = sub.SourceNodeIds[i];
|
|
var inFaultedDv = results[i * 2];
|
|
var severityDv = results[i * 2 + 1];
|
|
if (inFaultedDv.StatusCode != AbCipStatusMapper.Good) continue;
|
|
|
|
var nowFaulted = ToBool(inFaultedDv.Value);
|
|
var severity = ToInt(severityDv.Value);
|
|
|
|
var wasFaulted = sub.LastInFaulted.GetValueOrDefault(nodeId, false);
|
|
sub.LastInFaulted[nodeId] = nowFaulted;
|
|
|
|
if (!wasFaulted && nowFaulted)
|
|
{
|
|
_driver.InvokeAlarmEvent(new AlarmEventArgs(
|
|
sub.Handle, nodeId, ConditionId: $"{nodeId}#active",
|
|
AlarmType: "ALMD",
|
|
Message: $"ALMD {nodeId} raised",
|
|
Severity: MapSeverity(severity),
|
|
SourceTimestampUtc: DateTime.UtcNow));
|
|
}
|
|
else if (wasFaulted && !nowFaulted)
|
|
{
|
|
_driver.InvokeAlarmEvent(new AlarmEventArgs(
|
|
sub.Handle, nodeId, ConditionId: $"{nodeId}#active",
|
|
AlarmType: "ALMD",
|
|
Message: $"ALMD {nodeId} cleared",
|
|
Severity: MapSeverity(severity),
|
|
SourceTimestampUtc: DateTime.UtcNow));
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task RunPollLoopAsync(Subscription sub, CancellationToken ct)
|
|
{
|
|
var refs = new List<string>(sub.SourceNodeIds.Count * 2);
|
|
foreach (var nodeId in sub.SourceNodeIds)
|
|
{
|
|
refs.Add($"{nodeId}.InFaulted");
|
|
refs.Add($"{nodeId}.Severity");
|
|
}
|
|
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
try
|
|
{
|
|
var results = await _driver.ReadAsync(refs, ct).ConfigureAwait(false);
|
|
Tick(sub, results);
|
|
}
|
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
|
catch (Exception ex)
|
|
{
|
|
// Per-tick failures are non-fatal; next tick retries. Log at debug because a
|
|
// wedged controller produces one exception per tick and the operator already
|
|
// sees the failed-read warning from ReadAsync below this layer; this log just
|
|
// confirms the alarm projection loop is still running.
|
|
_logger.LogDebug(ex, "AbCip alarm-projection poll tick failed (will retry)");
|
|
}
|
|
|
|
try { await Task.Delay(_pollInterval, ct).ConfigureAwait(false); }
|
|
catch (OperationCanceledException) { break; }
|
|
}
|
|
}
|
|
|
|
/// <summary>Maps a raw severity value to an <see cref="AlarmSeverity"/> enum value.</summary>
|
|
/// <param name="raw">The raw severity value from the alarm data.</param>
|
|
/// <returns>The corresponding alarm severity level.</returns>
|
|
internal static AlarmSeverity MapSeverity(int raw) => raw switch
|
|
{
|
|
<= 250 => AlarmSeverity.Low,
|
|
<= 500 => AlarmSeverity.Medium,
|
|
<= 750 => AlarmSeverity.High,
|
|
_ => AlarmSeverity.Critical,
|
|
};
|
|
|
|
private static bool ToBool(object? v) => v switch
|
|
{
|
|
bool b => b,
|
|
int i => i != 0,
|
|
long l => l != 0,
|
|
_ => false,
|
|
};
|
|
|
|
private static int ToInt(object? v) => v switch
|
|
{
|
|
int i => i,
|
|
long l => (int)l,
|
|
short s => s,
|
|
byte b => b,
|
|
_ => 0,
|
|
};
|
|
|
|
internal sealed class Subscription
|
|
{
|
|
/// <summary>Initializes a new instance of the <see cref="Subscription"/> class.</summary>
|
|
/// <param name="handle">The subscription handle.</param>
|
|
/// <param name="sourceNodeIds">The source node identifiers to monitor.</param>
|
|
/// <param name="cts">The cancellation token source for stopping the subscription.</param>
|
|
public Subscription(AbCipAlarmSubscriptionHandle handle, IReadOnlyList<string> sourceNodeIds, CancellationTokenSource cts)
|
|
{
|
|
Handle = handle; SourceNodeIds = sourceNodeIds; Cts = cts;
|
|
}
|
|
|
|
/// <summary>Gets the subscription handle.</summary>
|
|
public AbCipAlarmSubscriptionHandle Handle { get; }
|
|
|
|
/// <summary>Gets the source node identifiers being monitored.</summary>
|
|
public IReadOnlyList<string> SourceNodeIds { get; }
|
|
|
|
/// <summary>Gets the cancellation token source for this subscription.</summary>
|
|
public CancellationTokenSource Cts { get; }
|
|
|
|
/// <summary>Gets or sets the polling loop task.</summary>
|
|
public Task Loop { get; set; } = Task.CompletedTask;
|
|
|
|
/// <summary>Gets the dictionary tracking the last known InFaulted state for each node.</summary>
|
|
public Dictionary<string, bool> LastInFaulted { get; } = new(StringComparer.Ordinal);
|
|
}
|
|
}
|
|
|
|
/// <summary>Handle returned by <see cref="AbCipAlarmProjection.SubscribeAsync"/>.</summary>
|
|
public sealed record AbCipAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle
|
|
{
|
|
/// <summary>Gets a diagnostic identifier for this subscription.</summary>
|
|
public string DiagnosticId => $"abcip-alarm-sub-{Id}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Detects the ALMD / ALMA signature in an <see cref="AbCipTagDefinition"/>'s declared
|
|
/// members. Used by both discovery (to stamp <c>IsAlarm=true</c> on the emitted
|
|
/// variable) + initial driver setup (to decide which tags the alarm projection owns).
|
|
/// </summary>
|
|
public static class AbCipAlarmDetector
|
|
{
|
|
/// <summary>
|
|
/// <c>true</c> when <paramref name="tag"/> is a Structure whose declared members match
|
|
/// the ALMD signature (<c>InFaulted</c> + <c>Acked</c> present). ALMA detection
|
|
/// (analog alarms with <c>HHLimit</c>/<c>HLimit</c>/<c>LLimit</c>/<c>LLLimit</c>)
|
|
/// ships as a follow-up.
|
|
/// </summary>
|
|
/// <param name="tag">The tag definition to check for ALMD signature.</param>
|
|
/// <returns>True if the tag has the ALMD alarm signature; false otherwise.</returns>
|
|
public static bool IsAlmd(AbCipTagDefinition tag)
|
|
{
|
|
if (tag.DataType != AbCipDataType.Structure || tag.Members is null) return false;
|
|
var names = tag.Members.Select(m => m.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
return names.Contains("InFaulted") && names.Contains("Acked");
|
|
}
|
|
}
|