Auto: twincat-5.1 — IAlarmSource via TC3 EventLogger (gated, scaffold)

Closes #316
This commit is contained in:
Joseph Doherty
2026-04-26 11:13:24 -04:00
parent 3babfb8a99
commit c88e0b6bed
13 changed files with 1238 additions and 7 deletions

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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: