From 4e80db4844da8422cb20c504b51746a7dd2333cc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 20 Apr 2026 04:24:40 -0400 Subject: [PATCH] =?UTF-8?q?AbCip=20IAlarmSource=20via=20ALMD=20projection?= =?UTF-8?q?=20(#177)=20=E2=80=94=20feature-flagged=20OFF=20by=20default;?= =?UTF-8?q?=20when=20enabled,=20polls=20declared=20ALMD=20UDT=20member=20f?= =?UTF-8?q?ields=20+=20raises=20OnAlarmEvent=20on=200=E2=86=921=20+=201?= =?UTF-8?q?=E2=86=920=20transitions.=20Closes=20task=20#177.=20The=20AB=20?= =?UTF-8?q?CIP=20driver=20now=20implements=20IAlarmSource=20so=20the=20gen?= =?UTF-8?q?eric-driver=20alarm=20dispatch=20path=20(PR=2014's=20sinks=20+?= =?UTF-8?q?=20the=20Server.Security.AuthorizationGate=20AlarmSubscribe/Ala?= =?UTF-8?q?rmAck=20invoker=20wrapping)=20can=20treat=20AB-backed=20alarms?= =?UTF-8?q?=20uniformly=20with=20Galaxy=20+=20OpcUaClient=20+=20FOCAS.=20P?= =?UTF-8?q?rojection=20is=20ALMD-only=20in=20this=20pass:=20the=20Logix=20?= =?UTF-8?q?ALMD=20(digital=20alarm)=20instruction's=20UDT=20shape=20is=20w?= =?UTF-8?q?ell-understood=20(InFaulted=20+=20Acked=20+=20Severity=20+=20In?= =?UTF-8?q?=20+=20Cfg=5FProgTime=20at=20stable=20member=20names)=20so=20th?= =?UTF-8?q?e=20polled-read=20+=20state-diff=20pattern=20fits=20without=20c?= =?UTF-8?q?oncessions.=20ALMA=20(analog=20alarm)=20deferred=20to=20a=20fol?= =?UTF-8?q?low-up=20because=20its=20HHLimit/HLimit/LLimit/LLLimit=20thresh?= =?UTF-8?q?old=20+=20In=20value=20semantics=20deserve=20their=20own=20desi?= =?UTF-8?q?gn=20pass=20=E2=80=94=20raising=20on=20threshold-crossing=20is?= =?UTF-8?q?=20not=20the=20same=20shape=20as=20raising=20on=20InFaulted-edg?= =?UTF-8?q?e.=20AbCipDriverOptions=20gains=20two=20knobs:=20EnableAlarmPro?= =?UTF-8?q?jection=20(default=20false)=20+=20AlarmPollInterval=20(default?= =?UTF-8?q?=201s).=20Explicit=20opt-in=20because=20projection=20semantics?= =?UTF-8?q?=20don't=20exactly=20mirror=20Rockwell=20FT=20Alarm=20&=20Event?= =?UTF-8?q?s;=20shops=20running=20FT=20Live=20should=20leave=20this=20off?= =?UTF-8?q?=20+=20take=20alarms=20through=20the=20native=20A&E=20route.=20?= =?UTF-8?q?AbCipAlarmProjection=20is=20the=20state=20machine:=20per-subscr?= =?UTF-8?q?iption=20background=20loop=20polls=20the=20source-node=20set=20?= =?UTF-8?q?via=20the=20driver's=20public=20ReadAsync=20=E2=80=94=20which?= =?UTF-8?q?=20gains=20the=20#194=20whole-UDT=20optimization=20for=20free?= =?UTF-8?q?=20when=20ALMDs=20are=20declared=20with=20their=20standard=20me?= =?UTF-8?q?mber=20set,=20so=20one=20poll=20tick=20reads=20(N=20alarms=20?= =?UTF-8?q?=C3=97=202=20members)=20=3D=20N=20libplctag=20round-trips=20rat?= =?UTF-8?q?her=20than=202N.=20Per-tick=20state=20diff:=20compare=20InFault?= =?UTF-8?q?ed=20+=20Severity=20against=20last-seen,=20fire=20raise=20(0?= =?UTF-8?q?=E2=86=921)=20/=20clear=20(1=E2=86=920)=20with=20AlarmSeverity?= =?UTF-8?q?=20bucketed=20via=20the=201-1000=20Logix=20severity=20scale=20(?= =?UTF-8?q?=E2=89=A4250=20Low,=20=E2=89=A4500=20Medium,=20=E2=89=A4750=20H?= =?UTF-8?q?igh,=20rest=20Critical=20=E2=80=94=20matches=20OpcUaClient's=20?= =?UTF-8?q?MapSeverity=20shape).=20ConditionId=20is=20{sourceNode}#active?= =?UTF-8?q?=20=E2=80=94=20matches=20a=20single=20active-branch=20per=20ala?= =?UTF-8?q?rm=20which=20is=20all=20ALMD=20supports;=20when=20Cfg=5FProgTim?= =?UTF-8?q?e-based=20branch=20identity=20becomes=20interesting=20(re-raise?= =?UTF-8?q?=20after=20ack=20with=20new=20timestamp),=20a=20richer=20Condit?= =?UTF-8?q?ionId=20pass=20can=20land.=20Subscribe-while-disabled=20returns?= =?UTF-8?q?=20a=20handle=20wrapping=20id=3D0=20=E2=80=94=20capability=20ne?= =?UTF-8?q?gotiation=20(the=20server=20queries=20IAlarmSource=20presence?= =?UTF-8?q?=20at=20driver-load=20time)=20still=20succeeds,=20the=20alarm?= =?UTF-8?q?=20surface=20just=20never=20fires.=20Unsubscribe=20cancels=20th?= =?UTF-8?q?e=20sub's=20CTS=20+=20awaits=20its=20loop;=20ShutdownAsync=20ca?= =?UTF-8?q?ncels=20every=20sub=20on=20its=20way=20out=20so=20a=20driver=20?= =?UTF-8?q?reload=20doesn't=20leak=20poll=20tasks.=20AcknowledgeAsync=20ro?= =?UTF-8?q?utes=20through=20the=20driver's=20existing=20WriteAsync=20path?= =?UTF-8?q?=20=E2=80=94=20per-ack=20writes=20{SourceNodeId}.Acked=20=3D=20?= =?UTF-8?q?true=20(the=20simpler=20semantic;=20operators=20whose=20ladder?= =?UTF-8?q?=20watches=20AckCmd=20+=20rising-edge=20can=20wire=20a=20client?= =?UTF-8?q?-side=20pulse=20until=20a=20driver-level=20edge-mode=20knob=20l?= =?UTF-8?q?ands).=20Best-effort=20=E2=80=94=20per-ack=20faults=20are=20swa?= =?UTF-8?q?llowed=20so=20one=20bad=20ack=20doesn't=20poison=20the=20whole?= =?UTF-8?q?=20batch.=20Six=20new=20AbCipAlarmProjectionTests:=20detector?= =?UTF-8?q?=20flags=20ALMD=20signature=20+=20skips=20non-signature=20UDTs?= =?UTF-8?q?=20+=20atomics;=20severity=20mapping=20matches=20OPC=20UA=20A&C?= =?UTF-8?q?=20bucket=20boundaries;=20feature-flag=20OFF=20returns=20a=20ha?= =?UTF-8?q?ndle=20but=20never=20touches=20the=20fake=20runtime=20(proving?= =?UTF-8?q?=20no=20background=20polling=20happens);=20feature-flag=20ON=20?= =?UTF-8?q?fires=20a=20raise=20event=20on=200=E2=86=921;=20clear=20event?= =?UTF-8?q?=20fires=20on=201=E2=86=920=20after=20a=20prior=20raise;=20unsu?= =?UTF-8?q?bscribe=20stops=20the=20poll=20loop=20(ReadCount=20doesn't=20gr?= =?UTF-8?q?ow=20past=20cancel=20+=20at=20most=20one=20straggler=20read).?= =?UTF-8?q?=20Driver=20builds=200=20errors;=20AbCip.Tests=20233/233=20(was?= =?UTF-8?q?=20227,=20+6=20new).=20Task=20#177=20closed=20=E2=80=94=20the?= =?UTF-8?q?=20last=20pending=20AB=20CIP=20follow-up=20is=20now=20#194=20(a?= =?UTF-8?q?lready=20shipped).=20Remaining=20pending=20fleet-wide:=20#150?= =?UTF-8?q?=20(Galaxy=20MXAccess=20failover=20hardware)=20+=20#199=20(UnsT?= =?UTF-8?q?ab=20Playwright=20smoke).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AbCipAlarmProjection.cs | 232 ++++++++++++++++++ .../AbCipDriver.cs | 42 +++- .../AbCipDriverOptions.cs | 18 ++ .../AbCipAlarmProjectionTests.cs | 190 ++++++++++++++ 4 files changed, 481 insertions(+), 1 deletion(-) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipAlarmProjection.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlarmProjectionTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipAlarmProjection.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipAlarmProjection.cs new file mode 100644 index 0000000..17a8f97 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipAlarmProjection.cs @@ -0,0 +1,232 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// +/// Task #177 — projects AB Logix ALMD alarm instructions onto the OPC UA alarm surface by +/// polling the ALMD UDT's InFaulted / Acked / Severity members at a +/// configurable interval + translating state transitions into OnAlarmEvent +/// callbacks on the owning . Feature-flagged off by default via +/// ; callers that leave the flag off +/// get a no-op subscribe path so capability negotiation still works. +/// +/// +/// 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 +/// contract without concessions. +/// +/// Polling reuses , 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. +/// +/// ALMD Acked write semantics on Logix are rising-edge sensitive at the +/// instruction level — writing Acked=1 directly is honored by FT View + the +/// standard HMI templates, but some PLC programs read AckCmd + look for the edge +/// themselves. We pick the simpler Acked write for first pass; operators whose +/// ladder watches AckCmd can wire a follow-up "AckCmd 0→1→0" pulse on the client +/// side until a driver-level knob lands. +/// +internal sealed class AbCipAlarmProjection : IAsyncDisposable +{ + private readonly AbCipDriver _driver; + private readonly TimeSpan _pollInterval; + private readonly Dictionary _subs = new(); + private readonly Lock _subsLock = new(); + private long _nextId; + + public AbCipAlarmProjection(AbCipDriver driver, TimeSpan pollInterval) + { + _driver = driver; + _pollInterval = pollInterval; + } + + public async Task SubscribeAsync( + IReadOnlyList 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; + } + + 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(); + } + + public async Task AcknowledgeAsync( + IReadOnlyList 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); + } + + public async ValueTask DisposeAsync() + { + List 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(); + } + } + + /// + /// Poll-tick body — reads InFaulted + Severity 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. + /// + internal void Tick(Subscription sub, IReadOnlyList 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(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 { /* per-tick failures are non-fatal; next tick retries */ } + + try { await Task.Delay(_pollInterval, ct).ConfigureAwait(false); } + catch (OperationCanceledException) { break; } + } + } + + 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 + { + public Subscription(AbCipAlarmSubscriptionHandle handle, IReadOnlyList sourceNodeIds, CancellationTokenSource cts) + { + Handle = handle; SourceNodeIds = sourceNodeIds; Cts = cts; + } + public AbCipAlarmSubscriptionHandle Handle { get; } + public IReadOnlyList SourceNodeIds { get; } + public CancellationTokenSource Cts { get; } + public Task Loop { get; set; } = Task.CompletedTask; + public Dictionary LastInFaulted { get; } = new(StringComparer.Ordinal); + } +} + +/// Handle returned by . +public sealed record AbCipAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle +{ + public string DiagnosticId => $"abcip-alarm-sub-{Id}"; +} + +/// +/// Detects the ALMD / ALMA signature in an 's declared +/// members. Used by both discovery (to stamp IsAlarm=true on the emitted +/// variable) + initial driver setup (to decide which tags the alarm projection owns). +/// +public static class AbCipAlarmDetector +{ + /// + /// true when is a Structure whose declared members match + /// the ALMD signature (InFaulted + Acked present). ALMA detection + /// (analog alarms with HHLimit/HLimit/LLimit/LLLimit) + /// ships as a follow-up. + /// + 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"); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index db43d52..b390164 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -21,7 +21,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; /// and reconnects each device. /// public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, - IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable + IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable { private readonly AbCipDriverOptions _options; private readonly string _driverInstanceId; @@ -32,10 +32,15 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, private readonly PollGroupEngine _poll; private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase); + private readonly AbCipAlarmProjection _alarmProjection; private DriverHealth _health = new(DriverState.Unknown, null, null); public event EventHandler? OnDataChange; public event EventHandler? OnHostStatusChanged; + public event EventHandler? OnAlarmEvent; + + /// Internal seam for the alarm projection to raise events through the driver. + internal void InvokeAlarmEvent(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args); public AbCipDriver(AbCipDriverOptions options, string driverInstanceId, IAbCipTagFactory? tagFactory = null, @@ -52,6 +57,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, reader: ReadAsync, onChange: (handle, tagRef, snapshot) => OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot))); + _alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval); } /// @@ -162,6 +168,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, public async Task ShutdownAsync(CancellationToken cancellationToken) { + await _alarmProjection.DisposeAsync().ConfigureAwait(false); await _poll.DisposeAsync().ConfigureAwait(false); foreach (var state in _devices.Values) { @@ -187,6 +194,39 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, return Task.CompletedTask; } + // ---- IAlarmSource (ALMD projection, #177) ---- + + /// + /// Subscribe to ALMD alarm transitions on . Each id + /// names a declared ALMD UDT tag; the projection polls the tag's InFaulted + + /// Severity members at and + /// fires on 0→1 (raise) + 1→0 (clear) transitions. + /// Feature-gated — when is + /// false (the default), returns a handle wrapping a no-op subscription so + /// capability negotiation still works; never fires. + /// + public Task SubscribeAlarmsAsync( + IReadOnlyList sourceNodeIds, CancellationToken cancellationToken) + { + if (!_options.EnableAlarmProjection) + { + var disabled = new AbCipAlarmSubscriptionHandle(0); + return Task.FromResult(disabled); + } + return _alarmProjection.SubscribeAsync(sourceNodeIds, cancellationToken); + } + + public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) => + _options.EnableAlarmProjection + ? _alarmProjection.UnsubscribeAsync(handle, cancellationToken) + : Task.CompletedTask; + + public Task AcknowledgeAsync( + IReadOnlyList acknowledgements, CancellationToken cancellationToken) => + _options.EnableAlarmProjection + ? _alarmProjection.AcknowledgeAsync(acknowledgements, cancellationToken) + : Task.CompletedTask; + // ---- IHostConnectivityProbe ---- public IReadOnlyList GetHostStatuses() => diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs index 469a64b..f251c78 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs @@ -38,6 +38,24 @@ public sealed class AbCipDriverOptions /// should appear in the address space. /// public bool EnableControllerBrowse { get; init; } + + /// + /// Task #177 — when true, declared ALMD tags are surfaced as alarm conditions + /// via ; the driver polls each subscribed + /// alarm's InFaulted + Severity members + fires OnAlarmEvent on + /// state transitions. Default false — operators explicitly opt in because + /// projection semantics don't exactly mirror Rockwell FT Alarm & Events; shops + /// running FT Live should keep this off + take alarms through the native route. + /// + public bool EnableAlarmProjection { get; init; } + + /// + /// Poll interval for the ALMD projection loop. Shorter intervals catch faster edges + /// at the cost of PLC round-trips; edges shorter than this interval are invisible to + /// the projection (a 0→1→0 transition within one tick collapses to no event). Default + /// 1 second — matches typical SCADA alarm-refresh conventions. + /// + public TimeSpan AlarmPollInterval { get; init; } = TimeSpan.FromSeconds(1); } /// diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlarmProjectionTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlarmProjectionTests.cs new file mode 100644 index 0000000..582ee37 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlarmProjectionTests.cs @@ -0,0 +1,190 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// Task #177 — tests covering ALMD projection detection, feature-flag gate, +/// subscribe/unsubscribe lifecycle, state-transition event emission, and acknowledge. +/// +[Trait("Category", "Unit")] +public sealed class AbCipAlarmProjectionTests +{ + private const string Device = "ab://10.0.0.5/1,0"; + + private static AbCipTagDefinition AlmdTag(string name) => new( + name, Device, name, AbCipDataType.Structure, Members: + [ + new AbCipStructureMember("InFaulted", AbCipDataType.DInt), // Logix stores ALMD bools as DINT + new AbCipStructureMember("Acked", AbCipDataType.DInt), + new AbCipStructureMember("Severity", AbCipDataType.DInt), + new AbCipStructureMember("In", AbCipDataType.DInt), + ]); + + [Fact] + public void AbCipAlarmDetector_Flags_AlmdSignature_As_Alarm() + { + var almd = AlmdTag("HighTemp"); + AbCipAlarmDetector.IsAlmd(almd).ShouldBeTrue(); + + var plainUdt = new AbCipTagDefinition("Plain", Device, "Plain", AbCipDataType.Structure, Members: + [new AbCipStructureMember("X", AbCipDataType.DInt)]); + AbCipAlarmDetector.IsAlmd(plainUdt).ShouldBeFalse(); + + var atomic = new AbCipTagDefinition("Plain", Device, "Plain", AbCipDataType.DInt); + AbCipAlarmDetector.IsAlmd(atomic).ShouldBeFalse(); + } + + [Fact] + public void Severity_Mapping_Matches_OPC_UA_Convention() + { + // Logix severity 1–1000 — mirror the OpcUaClient ACAndC bucketing. + AbCipAlarmProjection.MapSeverity(100).ShouldBe(AlarmSeverity.Low); + AbCipAlarmProjection.MapSeverity(400).ShouldBe(AlarmSeverity.Medium); + AbCipAlarmProjection.MapSeverity(600).ShouldBe(AlarmSeverity.High); + AbCipAlarmProjection.MapSeverity(900).ShouldBe(AlarmSeverity.Critical); + } + + [Fact] + public async Task FeatureFlag_Off_SubscribeAlarms_Returns_Handle_But_Never_Polls() + { + var factory = new FakeAbCipTagFactory(); + var opts = new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Device)], + Tags = [AlmdTag("HighTemp")], + EnableAlarmProjection = false, // explicit; also the default + }; + var drv = new AbCipDriver(opts, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None); + handle.ShouldNotBeNull(); + handle.DiagnosticId.ShouldContain("abcip-alarm-sub-"); + + // Wait a touch — if polling were active, a fake member-read would be triggered. + await Task.Delay(100); + factory.Tags.ShouldNotContainKey("HighTemp.InFaulted"); + factory.Tags.ShouldNotContainKey("HighTemp.Severity"); + + await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task FeatureFlag_On_Subscribe_Starts_Polling_And_Fires_Raise_On_0_to_1() + { + var factory = new FakeAbCipTagFactory(); + var opts = new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Device)], + Tags = [AlmdTag("HighTemp")], + EnableAlarmProjection = true, + AlarmPollInterval = TimeSpan.FromMilliseconds(20), + }; + var drv = new AbCipDriver(opts, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var events = new List(); + drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); }; + + var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None); + + // The ALMD UDT is declared so whole-UDT grouping kicks in; the parent HighTemp runtime + // gets created + polled. Set InFaulted offset-value to 0 first (clear), wait a tick, + // then flip to 1 (fault) + wait for the raise event. + await WaitForTagCreation(factory, "HighTemp"); + factory.Tags["HighTemp"].ValuesByOffset[0] = 0; // InFaulted=false at offset 0 + factory.Tags["HighTemp"].ValuesByOffset[8] = 500; // Severity at offset 8 (after InFaulted+Acked) + await Task.Delay(80); // let a tick seed the "last-seen false" state + + factory.Tags["HighTemp"].ValuesByOffset[0] = 1; // flip to faulted + await Task.Delay(200); // allow several polls to be safe + + lock (events) + { + events.ShouldContain(e => e.SourceNodeId == "HighTemp" && e.AlarmType == "ALMD" + && e.Message.Contains("raised")); + } + + await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task Clear_Event_Fires_On_1_to_0_Transition() + { + var factory = new FakeAbCipTagFactory(); + var opts = new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Device)], + Tags = [AlmdTag("HighTemp")], + EnableAlarmProjection = true, + AlarmPollInterval = TimeSpan.FromMilliseconds(20), + }; + var drv = new AbCipDriver(opts, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var events = new List(); + drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); }; + + var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None); + await WaitForTagCreation(factory, "HighTemp"); + + factory.Tags["HighTemp"].ValuesByOffset[0] = 1; + factory.Tags["HighTemp"].ValuesByOffset[8] = 500; + await Task.Delay(80); // observe raise + + factory.Tags["HighTemp"].ValuesByOffset[0] = 0; + await Task.Delay(200); + + lock (events) + { + events.ShouldContain(e => e.Message.Contains("raised")); + events.ShouldContain(e => e.Message.Contains("cleared")); + } + + await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task Unsubscribe_Stops_The_Poll_Loop() + { + var factory = new FakeAbCipTagFactory(); + var opts = new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Device)], + Tags = [AlmdTag("HighTemp")], + EnableAlarmProjection = true, + AlarmPollInterval = TimeSpan.FromMilliseconds(20), + }; + var drv = new AbCipDriver(opts, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None); + await WaitForTagCreation(factory, "HighTemp"); + var preUnsubReadCount = factory.Tags["HighTemp"].ReadCount; + + await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None); + await Task.Delay(100); // well past several poll intervals if the loop were still alive + + var postDelayReadCount = factory.Tags["HighTemp"].ReadCount; + // Allow at most one straggler read between the unsubscribe-cancel + the loop exit. + (postDelayReadCount - preUnsubReadCount).ShouldBeLessThanOrEqualTo(1); + + await drv.ShutdownAsync(CancellationToken.None); + } + + private static async Task WaitForTagCreation(FakeAbCipTagFactory factory, string tagName) + { + var deadline = DateTime.UtcNow.AddSeconds(2); + while (DateTime.UtcNow < deadline) + { + if (factory.Tags.ContainsKey(tagName)) return; + await Task.Delay(10); + } + throw new TimeoutException($"Tag {tagName} was never created by the fake factory."); + } +}