Auto: twincat-5.1 — IAlarmSource via TC3 EventLogger (gated, scaffold)
Closes #316
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// PR 5.1 / #316 — end-to-end alarm-integration scaffold against a live TwinCAT 3 XAR
|
||||
/// runtime. Skipped via <see cref="TwinCATFactAttribute"/> when the VM isn't reachable.
|
||||
/// Proves the driver's <see cref="IAlarmSource"/> bridge surfaces TC3 EventLogger events
|
||||
/// when the PLC's <c>FB_AlarmHarness</c> calls <c>FB_TcLogEvent</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Required VM project state</b> (see <c>TwinCatProject/README.md</c>
|
||||
/// §"Alarm scenarios"):</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>GVL <c>GVL_Alarms</c> with <c>bTriggerEvent : BOOL</c> + <c>bAcked : BOOL</c>.
|
||||
/// A test harness flips <c>bTriggerEvent</c> from <c>FALSE</c> to <c>TRUE</c> via the
|
||||
/// driver's <c>WriteAsync</c> path; <c>FB_AlarmHarness</c> sees the rising edge +
|
||||
/// calls <c>FB_TcLogEvent</c> on the PLC side.</item>
|
||||
/// <item>FB <c>FB_AlarmHarness</c> wired into <c>MAIN</c> that calls <c>FB_TcLogEvent</c>
|
||||
/// with a configured event class GUID + severity + source string when
|
||||
/// <c>GVL_Alarms.bTriggerEvent</c> rises.</item>
|
||||
/// </list>
|
||||
/// <para><b>Decode caveat</b> — Beckhoff doesn't ship a managed wrapper for
|
||||
/// <c>TcEventLogger</c> in <c>Beckhoff.TwinCAT.Ads</c> v6, so the production
|
||||
/// gate is best-effort and several event fields may surface as <c>"Unknown"</c>.
|
||||
/// This test asserts the bridge fires + the event has non-empty content; field-level
|
||||
/// decode tightening lands on a follow-up PR (see
|
||||
/// <c>docs/v3/twincat-eventlogger-spike.md</c>).</para>
|
||||
/// </remarks>
|
||||
[Collection("TwinCATXar")]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Simulator", "TwinCAT-XAR")]
|
||||
public sealed class TwinCATAlarmIntegrationTests(TwinCATXarFixture sim)
|
||||
{
|
||||
[TwinCATFact]
|
||||
public async Task Driver_raises_alarm_event_when_PLC_logs_event()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
// Fixture-side state is documented in TwinCatProject/README.md §"Alarm scenarios".
|
||||
// The harness is currently a build-only placeholder — once the GVL + FB_AlarmHarness
|
||||
// ship, replace the Skip below with the live-trigger flow:
|
||||
// 1. Init driver with EnableAlarms=true + GVL_Alarms.bTriggerEvent declared as a
|
||||
// writable BOOL tag.
|
||||
// 2. SubscribeAlarmsAsync([], ct).
|
||||
// 3. WriteAsync to flip bTriggerEvent from FALSE to TRUE — the PLC's
|
||||
// FB_AlarmHarness sees the rising edge + calls FB_TcLogEvent.
|
||||
// 4. Assert OnAlarmEvent fires within ~5s with a non-empty Source + Message.
|
||||
Assert.Skip(
|
||||
"PR 5.1 / #316 — alarm-integration build-only scaffold. The GVL_Alarms + " +
|
||||
"FB_AlarmHarness fixture hasn't been authored on the XAR project yet (build-time " +
|
||||
"stubs ship under TwinCatProject/PLC; live trigger lands once the XAR project " +
|
||||
"imports them). Until then this test self-skips even when the runtime is up.");
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the AMS options with <see cref="TwinCATDriverOptions.EnableAlarms"/> on so
|
||||
/// the driver instantiates the alarm source. Mirrors the smoke-test option builder
|
||||
/// but flips the alarm gate on; once the live fixture lands, the test body above
|
||||
/// calls this helper directly.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage(
|
||||
"Style", "IDE0051", Justification = "Used by the live test body once the fixture ships.")]
|
||||
private static TwinCATDriverOptions BuildAlarmOptions(TwinCATXarFixture sim) => new()
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions(
|
||||
HostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
|
||||
DeviceName: $"xar-{sim.TargetNetId}:{sim.AmsPort}")],
|
||||
Tags =
|
||||
[
|
||||
// bTriggerEvent rises FALSE→TRUE to fire the PLC-side LogEvent call.
|
||||
new TwinCATTagDefinition(
|
||||
Name: "AlarmTrigger",
|
||||
DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
|
||||
SymbolPath: "GVL_Alarms.bTriggerEvent",
|
||||
DataType: TwinCATDataType.Bool,
|
||||
Writable: true),
|
||||
],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableAlarms = true,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Helper kept for parity with the live test body once the fixture ships — collects
|
||||
/// <see cref="IAlarmSource.OnAlarmEvent"/> off the driver into a queue caller can
|
||||
/// drain after the trigger flip.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage(
|
||||
"Style", "IDE0051", Justification = "Used by the live test body once the fixture ships.")]
|
||||
private static ConcurrentQueue<AlarmEventArgs> WireAlarmCollector(IAlarmSource src)
|
||||
{
|
||||
var q = new ConcurrentQueue<AlarmEventArgs>();
|
||||
src.OnAlarmEvent += (_, e) => q.Enqueue(e);
|
||||
return q;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<GVL Name="GVL_Alarms" Id="{00000000-0000-0000-0000-000000000505}">
|
||||
<Declaration><![CDATA[// PR 5.1 / #316 — TC3 EventLogger fixture for TwinCATAlarmIntegrationTests.
|
||||
// bTriggerEvent rises FALSE -> TRUE to fire one EventLogger event via FB_AlarmHarness.
|
||||
// bAcked is operator-side ACK toggle the alarm-source bridge writes back when the
|
||||
// driver's AcknowledgeAsync runs. nLastEventClass / nLastSeverity track the last
|
||||
// event the harness raised so the integration test can sanity-check the values it
|
||||
// expects to surface through IAlarmSource.
|
||||
VAR_GLOBAL
|
||||
bTriggerEvent : BOOL := FALSE;
|
||||
bAcked : BOOL := FALSE;
|
||||
nLastEventClass : DINT := 0;
|
||||
nLastSeverity : USINT := 0;
|
||||
fbAlarmHarness : FB_AlarmHarness;
|
||||
END_VAR
|
||||
]]></Declaration>
|
||||
</GVL>
|
||||
</TcPlcObject>
|
||||
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<POU Name="FB_AlarmHarness" Id="{00000000-0000-0000-0000-000000000303}" SpecialFunc="None">
|
||||
<Declaration><![CDATA[// PR 5.1 / #316 — drives the TC3 EventLogger so TwinCATAlarmIntegrationTests
|
||||
// can observe an event surfacing through the driver's IAlarmSource bridge.
|
||||
//
|
||||
// On a rising edge of GVL_Alarms.bTriggerEvent the harness calls FB_TcLogEvent
|
||||
// with a fixed event class GUID + severity from GVL_Alarms.nLastSeverity and a
|
||||
// short message string. The wire side of the EventLogger then dispatches a
|
||||
// notification on AMS port 110 (AMSPORT_EVENTLOG); the driver's secondary
|
||||
// AdsClient receives the event + projects it onto OnAlarmEvent.
|
||||
//
|
||||
// The harness intentionally targets a single event class GUID per fixture cycle;
|
||||
// the test asserts shape + presence rather than per-event-class decoding because
|
||||
// the binary protocol is undocumented in managed code (see
|
||||
// docs/v3/twincat-eventlogger-spike.md).
|
||||
FUNCTION_BLOCK FB_AlarmHarness
|
||||
VAR
|
||||
fbTrigger : R_TRIG;
|
||||
fbLogEvent : FB_TcLogEvent; // declared in Tc3_EventLogger
|
||||
sMessage : STRING(255) := 'Integration-fixture EventLogger trigger';
|
||||
END_VAR
|
||||
]]></Declaration>
|
||||
<Implementation>
|
||||
<ST><![CDATA[fbTrigger(CLK := GVL_Alarms.bTriggerEvent);
|
||||
IF fbTrigger.Q THEN
|
||||
// Fixed event-class GUID for the integration fixture; replace with whatever
|
||||
// class the operator wires into the TC3 EventLogger configuration GUI.
|
||||
fbLogEvent.ipMessage := 0; // placeholder — TwinCAT 3 ships richer
|
||||
// overloads; the integration test only
|
||||
// asserts an event surfaces, not the
|
||||
// specific payload bytes.
|
||||
fbLogEvent.eSeverity := TcEventSeverity.Warning;
|
||||
fbLogEvent.bConfirmable := TRUE;
|
||||
fbLogEvent.Execute(bExecute := TRUE);
|
||||
|
||||
GVL_Alarms.nLastEventClass := 1; // fixture-side echo so a watch window can
|
||||
// confirm the harness fired.
|
||||
GVL_Alarms.nLastSeverity := 100;
|
||||
END_IF
|
||||
fbLogEvent.Execute(bExecute := FALSE);
|
||||
]]></ST>
|
||||
</Implementation>
|
||||
</POU>
|
||||
</TcPlcObject>
|
||||
@@ -278,6 +278,88 @@ dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests `
|
||||
--filter "FullyQualifiedName~TwinCATSymbolVersionTests"
|
||||
```
|
||||
|
||||
## Alarm scenarios
|
||||
|
||||
PR 5.1 (#316) ships an opt-in TC3 EventLogger bridge. The driver's
|
||||
`IAlarmSource` implementation surfaces alarms by opening a second
|
||||
`AdsClient` against AMS port `110` (`AMSPORT_EVENTLOG`) and adding a
|
||||
device notification on `ADSIGRP_TCEVENTLOG_ALARMS`. The decode is
|
||||
best-effort because Beckhoff doesn't ship a managed `TcEventLogger`
|
||||
wrapper (only C++ TcCOM headers); some fields surface as `Unknown`
|
||||
until a follow-up PR lands a binary-protocol decoder. Spike output
|
||||
captured at `docs/v3/twincat-eventlogger-spike.md`.
|
||||
|
||||
The integration test
|
||||
(`TwinCATAlarmIntegrationTests.Driver_raises_alarm_event_when_PLC_logs_event`)
|
||||
ships build-only in PR 5.1 — once the XAR project imports the GVL +
|
||||
FB_AlarmHarness below, swap the `Assert.Skip` in the test body for the
|
||||
live flow:
|
||||
|
||||
1. Init the driver with `EnableAlarms=true`.
|
||||
2. `SubscribeAlarmsAsync([], ct)`.
|
||||
3. `WriteAsync` to flip `GVL_Alarms.bTriggerEvent` from `FALSE` to
|
||||
`TRUE` — `FB_AlarmHarness` sees the rising edge and calls
|
||||
`FB_TcLogEvent` on the PLC side.
|
||||
4. Assert `OnAlarmEvent` fires within `~5 s` with non-empty
|
||||
`Source` + `Message`.
|
||||
|
||||
### Global Variable List: `GVL_Alarms`
|
||||
|
||||
```st
|
||||
VAR_GLOBAL
|
||||
bTriggerEvent : BOOL := FALSE;
|
||||
bAcked : BOOL := FALSE;
|
||||
nLastEventClass : DINT := 0;
|
||||
nLastSeverity : USINT := 0;
|
||||
fbAlarmHarness : FB_AlarmHarness;
|
||||
END_VAR
|
||||
```
|
||||
|
||||
The XAE-form GVL ships at `PLC/GVLs/GVL_Alarms.TcGVL`; import it
|
||||
alongside the other fixture GVLs.
|
||||
|
||||
### POU: `FB_AlarmHarness`
|
||||
|
||||
```st
|
||||
FUNCTION_BLOCK FB_AlarmHarness
|
||||
VAR
|
||||
fbTrigger : R_TRIG;
|
||||
fbLogEvent : FB_TcLogEvent; // declared in Tc3_EventLogger
|
||||
sMessage : STRING(255) := 'Integration-fixture EventLogger trigger';
|
||||
END_VAR
|
||||
|
||||
fbTrigger(CLK := GVL_Alarms.bTriggerEvent);
|
||||
IF fbTrigger.Q THEN
|
||||
fbLogEvent.eSeverity := TcEventSeverity.Warning;
|
||||
fbLogEvent.bConfirmable := TRUE;
|
||||
fbLogEvent.Execute(bExecute := TRUE);
|
||||
GVL_Alarms.nLastEventClass := 1;
|
||||
GVL_Alarms.nLastSeverity := 100;
|
||||
END_IF
|
||||
fbLogEvent.Execute(bExecute := FALSE);
|
||||
```
|
||||
|
||||
The XAE-form POU ships at `PLC/POUs/FB_AlarmHarness.TcPOU`. Wire it
|
||||
into `MAIN`:
|
||||
|
||||
```st
|
||||
GVL_Alarms.fbAlarmHarness();
|
||||
```
|
||||
|
||||
### Event class IDs / severity buckets / cleared-on transitions
|
||||
|
||||
| Symbol | Value | Notes |
|
||||
| --- | --- | --- |
|
||||
| `nLastEventClass` | `DINT`, fixture-side echo (`1` after a rising edge) | Watch-window aid; the actual EventLogger event class is configured in the TC3 GUI per project. |
|
||||
| `nLastSeverity` | `USINT`, fixed `100` after a rising edge | Maps to `AlarmSeverity.Medium` via `TwinCATAlarmSource.MapSeverity` (≤128 = Medium). |
|
||||
| `bTriggerEvent` | `BOOL`, operator/test writes | Rising edge only — flip back to `FALSE` then `TRUE` to re-fire. |
|
||||
| `bAcked` | `BOOL`, driver writes when `AcknowledgeAsync` runs | Cleared by next event raise. |
|
||||
|
||||
The TC3 EventLogger surfaces the cleared transition automatically when
|
||||
`fbLogEvent.bConfirmable=TRUE` and an operator confirms; the driver
|
||||
projects the clear as a second `OnAlarmEvent` with the same condition
|
||||
id.
|
||||
|
||||
## How to run the TwinCAT-tier tests
|
||||
|
||||
On the dev box:
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PR 5.1 / #316 — covers the <see cref="IAlarmSource"/> shape on
|
||||
/// <see cref="TwinCATDriver"/>: feature-gating, gate event projection, multi-event
|
||||
/// ordering, acknowledge round-trip, and JSON DTO round-trip on the options.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TwinCATAlarmSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task EnableAlarms_false_does_not_create_alarm_source()
|
||||
{
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableAlarms = false,
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.HasAlarmSource.ShouldBeFalse();
|
||||
|
||||
var handle = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||
handle.ShouldBeOfType<TwinCATAlarmSubscriptionHandle>();
|
||||
((TwinCATAlarmSubscriptionHandle)handle).Id.ShouldBe(0);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnableAlarms_false_OnAlarmEvent_never_fires()
|
||||
{
|
||||
var gate = new FakeTwinCATAlarmGate();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableAlarms = false,
|
||||
}, "drv-1", alarmGate: gate);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var raised = new ConcurrentQueue<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => raised.Enqueue(e);
|
||||
_ = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||
|
||||
// Even if a stray event is fired through the gate (a buggy operator wired in a
|
||||
// fake), the disabled-mode driver doesn't subscribe + the event is dropped.
|
||||
gate.RaiseAlarm(new TwinCATAlarmEvent("Class.A", "Source1", 100, "msg", DateTimeOffset.UtcNow, false));
|
||||
await Task.Delay(20);
|
||||
|
||||
raised.ShouldBeEmpty();
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnableAlarms_true_creates_source_and_starts_gate_on_first_subscribe()
|
||||
{
|
||||
var gate = new FakeTwinCATAlarmGate();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableAlarms = true,
|
||||
}, "drv-1", alarmGate: gate);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
drv.HasAlarmSource.ShouldBeTrue();
|
||||
|
||||
gate.StartCount.ShouldBe(0);
|
||||
_ = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||
gate.StartCount.ShouldBe(1);
|
||||
|
||||
// Second subscribe doesn't restart the gate.
|
||||
_ = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||
gate.StartCount.ShouldBe(1);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gate_event_raises_AlarmEvent_on_driver_with_correct_shape()
|
||||
{
|
||||
var gate = new FakeTwinCATAlarmGate();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableAlarms = true,
|
||||
}, "drv-1", alarmGate: gate);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var raised = new ConcurrentQueue<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => raised.Enqueue(e);
|
||||
_ = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||
|
||||
var stamp = DateTimeOffset.UtcNow;
|
||||
gate.RaiseAlarm(new TwinCATAlarmEvent(
|
||||
EventClass: "TcEventClass.MachineFault",
|
||||
Source: "Conveyor1.MotorOverload",
|
||||
Severity: 200,
|
||||
Message: "Motor overload tripped",
|
||||
OccurrenceUtc: stamp,
|
||||
Acked: false));
|
||||
|
||||
raised.Count.ShouldBe(1);
|
||||
var args = raised.First();
|
||||
args.SourceNodeId.ShouldBe("Conveyor1.MotorOverload");
|
||||
args.AlarmType.ShouldBe("TcEventClass.MachineFault");
|
||||
args.Message.ShouldBe("Motor overload tripped");
|
||||
args.Severity.ShouldBe(AlarmSeverity.Critical);
|
||||
args.SourceTimestampUtc.ShouldBe(stamp.UtcDateTime);
|
||||
args.ConditionId.ShouldBe("Conveyor1.MotorOverload#TcEventClass.MachineFault");
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Multiple_alarm_events_are_delivered_in_order()
|
||||
{
|
||||
var gate = new FakeTwinCATAlarmGate();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableAlarms = true,
|
||||
}, "drv-1", alarmGate: gate);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var raised = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { lock (raised) raised.Add(e); };
|
||||
_ = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||
|
||||
var t = DateTimeOffset.UtcNow;
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
gate.RaiseAlarm(new TwinCATAlarmEvent(
|
||||
"Class.X", $"Source{i}", (ushort)(50 + i * 10), $"msg{i}", t.AddMilliseconds(i), false));
|
||||
}
|
||||
|
||||
raised.Count.ShouldBe(5);
|
||||
for (var i = 0; i < 5; i++)
|
||||
raised[i].SourceNodeId.ShouldBe($"Source{i}");
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SourceFilter_only_passes_matching_source()
|
||||
{
|
||||
var gate = new FakeTwinCATAlarmGate();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableAlarms = true,
|
||||
}, "drv-1", alarmGate: gate);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var raised = new ConcurrentQueue<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => raised.Enqueue(e);
|
||||
_ = await drv.SubscribeAlarmsAsync(["Conveyor1"], CancellationToken.None);
|
||||
|
||||
gate.RaiseAlarm(new TwinCATAlarmEvent("C", "Conveyor1", 100, "x", DateTimeOffset.UtcNow, false));
|
||||
gate.RaiseAlarm(new TwinCATAlarmEvent("C", "OtherSource", 100, "y", DateTimeOffset.UtcNow, false));
|
||||
gate.RaiseAlarm(new TwinCATAlarmEvent("C", "conveyor1", 100, "z", DateTimeOffset.UtcNow, false)); // case-insensitive
|
||||
|
||||
raised.Count.ShouldBe(2);
|
||||
raised.ShouldAllBe(e => string.Equals(e.SourceNodeId, "Conveyor1", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Acknowledge_round_trips_to_gate()
|
||||
{
|
||||
var gate = new FakeTwinCATAlarmGate();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableAlarms = true,
|
||||
}, "drv-1", alarmGate: gate);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
_ = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||
|
||||
await drv.AcknowledgeAsync(
|
||||
[new AlarmAcknowledgeRequest("Conveyor1", "cond-1", "operator A")],
|
||||
CancellationToken.None);
|
||||
|
||||
gate.AckLog.Count.ShouldBe(1);
|
||||
gate.AckLog.Single().SourceNodeId.ShouldBe("Conveyor1");
|
||||
gate.AckLog.Single().ConditionId.ShouldBe("cond-1");
|
||||
gate.AckLog.Single().Comment.ShouldBe("operator A");
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Acknowledge_when_disabled_is_noop()
|
||||
{
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableAlarms = false,
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Should complete without throwing even though no source is wired.
|
||||
await drv.AcknowledgeAsync(
|
||||
[new AlarmAcknowledgeRequest("X", "Y", null)], CancellationToken.None);
|
||||
await drv.UnsubscribeAlarmsAsync(new TwinCATAlarmSubscriptionHandle(0), CancellationToken.None);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsubscribe_stops_event_delivery()
|
||||
{
|
||||
var gate = new FakeTwinCATAlarmGate();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableAlarms = true,
|
||||
}, "drv-1", alarmGate: gate);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var raised = new ConcurrentQueue<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => raised.Enqueue(e);
|
||||
var handle = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||
|
||||
gate.RaiseAlarm(new TwinCATAlarmEvent("C", "S", 50, "before", DateTimeOffset.UtcNow, false));
|
||||
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||||
gate.RaiseAlarm(new TwinCATAlarmEvent("C", "S", 50, "after", DateTimeOffset.UtcNow, false));
|
||||
|
||||
raised.Count.ShouldBe(1);
|
||||
raised.First().Message.ShouldBe("before");
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Severity_mapping_buckets_match_quartile_cuts()
|
||||
{
|
||||
TwinCATAlarmSource.MapSeverity(0).ShouldBe(AlarmSeverity.Low);
|
||||
TwinCATAlarmSource.MapSeverity(64).ShouldBe(AlarmSeverity.Low);
|
||||
TwinCATAlarmSource.MapSeverity(65).ShouldBe(AlarmSeverity.Medium);
|
||||
TwinCATAlarmSource.MapSeverity(128).ShouldBe(AlarmSeverity.Medium);
|
||||
TwinCATAlarmSource.MapSeverity(129).ShouldBe(AlarmSeverity.High);
|
||||
TwinCATAlarmSource.MapSeverity(192).ShouldBe(AlarmSeverity.High);
|
||||
TwinCATAlarmSource.MapSeverity(193).ShouldBe(AlarmSeverity.Critical);
|
||||
TwinCATAlarmSource.MapSeverity(255).ShouldBe(AlarmSeverity.Critical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Options_round_trip_preserves_EnableAlarms()
|
||||
{
|
||||
var original = new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851", DeviceName: "Mach1")],
|
||||
EnableAlarms = true,
|
||||
};
|
||||
var json = JsonSerializer.Serialize(original);
|
||||
var restored = JsonSerializer.Deserialize<TwinCATDriverOptions>(json);
|
||||
|
||||
restored.ShouldNotBeNull();
|
||||
restored.EnableAlarms.ShouldBeTrue();
|
||||
|
||||
var defaultRestored = JsonSerializer.Deserialize<TwinCATDriverOptions>("{}");
|
||||
defaultRestored.ShouldNotBeNull();
|
||||
defaultRestored.EnableAlarms.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake alarm gate — captures Start invocations + ack requests, exposes
|
||||
/// <see cref="RaiseAlarm"/> so tests can drive synthetic events without standing up
|
||||
/// a second AMS-port-110 session against a real TC3 EventLogger.
|
||||
/// </summary>
|
||||
private sealed class FakeTwinCATAlarmGate : ITwinCATAlarmGate
|
||||
{
|
||||
public int StartCount { get; private set; }
|
||||
public List<AlarmAcknowledgeRequest> AckLog { get; } = new();
|
||||
public List<TwinCATAlarmEvent> ActiveAlarmsList { get; } = new();
|
||||
public IReadOnlyList<TwinCATAlarmEvent> ActiveAlarms => ActiveAlarmsList;
|
||||
|
||||
public event EventHandler<TwinCATAlarmEvent>? OnAlarmEvent;
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
StartCount++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
AckLog.AddRange(acknowledgements);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void RaiseAlarm(TwinCATAlarmEvent evt) => OnAlarmEvent?.Invoke(this, evt);
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user