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.");
+ }
+}