AbCip IAlarmSource via ALMD projection (#177) — feature-flagged OFF by default; when enabled, polls declared ALMD UDT member fields + raises OnAlarmEvent on 0→1 + 1→0 transitions. Closes task #177. The AB CIP driver now implements IAlarmSource so the generic-driver alarm dispatch path (PR 14's sinks + the Server.Security.AuthorizationGate AlarmSubscribe/AlarmAck invoker wrapping) can treat AB-backed alarms uniformly with Galaxy + OpcUaClient + FOCAS. Projection is ALMD-only in this pass: the Logix ALMD (digital alarm) instruction's UDT shape is well-understood (InFaulted + Acked + Severity + In + Cfg_ProgTime at stable member names) so the polled-read + state-diff pattern fits without concessions. ALMA (analog alarm) deferred to a follow-up because its HHLimit/HLimit/LLimit/LLLimit threshold + In value semantics deserve their own design pass — raising on threshold-crossing is not the same shape as raising on InFaulted-edge. AbCipDriverOptions gains two knobs: EnableAlarmProjection (default false) + AlarmPollInterval (default 1s). Explicit opt-in because projection semantics don't exactly mirror Rockwell FT Alarm & Events; shops running FT Live should leave this off + take alarms through the native A&E route. AbCipAlarmProjection is the state machine: per-subscription background loop polls the source-node set via the driver's public ReadAsync — which gains the #194 whole-UDT optimization for free when ALMDs are declared with their standard member set, so one poll tick reads (N alarms × 2 members) = N libplctag round-trips rather than 2N. Per-tick state diff: compare InFaulted + Severity against last-seen, fire raise (0→1) / clear (1→0) with AlarmSeverity bucketed via the 1-1000 Logix severity scale (≤250 Low, ≤500 Medium, ≤750 High, rest Critical — matches OpcUaClient's MapSeverity shape). ConditionId is {sourceNode}#active — matches a single active-branch per alarm which is all ALMD supports; when Cfg_ProgTime-based branch identity becomes interesting (re-raise after ack with new timestamp), a richer ConditionId pass can land. Subscribe-while-disabled returns a handle wrapping id=0 — capability negotiation (the server queries IAlarmSource presence at driver-load time) still succeeds, the alarm surface just never fires. Unsubscribe cancels the sub's CTS + awaits its loop; ShutdownAsync cancels every sub on its way out so a driver reload doesn't leak poll tasks. AcknowledgeAsync routes through the driver's existing WriteAsync path — per-ack writes {SourceNodeId}.Acked = true (the simpler semantic; operators whose ladder watches AckCmd + rising-edge can wire a client-side pulse until a driver-level edge-mode knob lands). Best-effort — per-ack faults are swallowed so one bad ack doesn't poison the whole batch. Six new AbCipAlarmProjectionTests: detector flags ALMD signature + skips non-signature UDTs + atomics; severity mapping matches OPC UA A&C bucket boundaries; feature-flag OFF returns a handle but never touches the fake runtime (proving no background polling happens); feature-flag ON fires a raise event on 0→1; clear event fires on 1→0 after a prior raise; unsubscribe stops the poll loop (ReadCount doesn't grow past cancel + at most one straggler read). Driver builds 0 errors; AbCip.Tests 233/233 (was 227, +6 new). Task #177 closed — the last pending AB CIP follow-up is now #194 (already shipped). Remaining pending fleet-wide: #150 (Galaxy MXAccess failover hardware) + #199 (UnsTab Playwright smoke).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task #177 — tests covering ALMD projection detection, feature-flag gate,
|
||||
/// subscribe/unsubscribe lifecycle, state-transition event emission, and acknowledge.
|
||||
/// </summary>
|
||||
[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<AlarmEventArgs>();
|
||||
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<AlarmEventArgs>();
|
||||
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.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user