From c88e0b6bed6f7c763a18a4e901aebeadfd08010f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 11:13:24 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20twincat-5.1=20=E2=80=94=20IAlarmSource?= =?UTF-8?q?=20via=20TC3=20EventLogger=20(gated,=20scaffold)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #316 --- docs/Driver.TwinCAT.Cli.md | 34 ++ docs/drivers/TwinCAT-Test-Fixture.md | 25 +- docs/drivers/TwinCAT.md | 115 +++++++ docs/v3/twincat-eventlogger-spike.md | 101 ++++++ .../Commands/AlarmsCommand.cs | 94 ++++++ .../TwinCATAlarmSource.cs | 224 +++++++++++++ .../TwinCATDriver.cs | 73 +++- .../TwinCATDriverOptions.cs | 17 + .../TwinCATAlarmIntegrationTests.cs | 102 ++++++ .../TwinCatProject/PLC/GVLs/GVL_Alarms.TcGVL | 19 ++ .../PLC/POUs/FB_AlarmHarness.TcPOU | 45 +++ .../TwinCatProject/README.md | 82 +++++ .../TwinCATAlarmSourceTests.cs | 314 ++++++++++++++++++ 13 files changed, 1238 insertions(+), 7 deletions(-) create mode 100644 docs/drivers/TwinCAT.md create mode 100644 docs/v3/twincat-eventlogger-spike.md create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/AlarmsCommand.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATAlarmSource.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATAlarmIntegrationTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/GVLs/GVL_Alarms.TcGVL create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/POUs/FB_AlarmHarness.TcPOU create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATAlarmSourceTests.cs diff --git a/docs/Driver.TwinCAT.Cli.md b/docs/Driver.TwinCAT.Cli.md index e83b0e6..6441f05 100644 --- a/docs/Driver.TwinCAT.Cli.md +++ b/docs/Driver.TwinCAT.Cli.md @@ -217,3 +217,37 @@ in screen-recorded bug reports. `--poll-only` polls go through the same cached-handle path as `read`, so repeated polls of the same symbol carry only a 4-byte handle on the wire rather than the full symbolic path. + +### `alarms` (PR 5.1 / #316) + +Stream TC3 EventLogger alarms via the driver's `IAlarmSource` bridge. +Subscribes against AMS port 110 (`AMSPORT_EVENTLOG`) on the same target, +prints each event with timestamp / source / severity / message until +Ctrl+C. + +```powershell +# All alarms — every event the EventLogger surfaces +otopcua-twincat-cli alarms -n 192.168.1.40.1.1 + +# Filter by source — only events whose source name matches (case-insensitive) +otopcua-twincat-cli alarms -n 192.168.1.40.1.1 --source Conveyor1.MotorOverload + +# Multiple sources — repeat the flag +otopcua-twincat-cli alarms -n 192.168.1.40.1.1 --source Conveyor1 --source Pump3 +``` + +| Flag | Default | Purpose | +|---|---|---| +| `--source` | (none) | Optional source filter; repeat for multiple | + +Output format (one line per event): + +``` +[HH:mm:ss.fff] sev= type= cond= "" +``` + +The verb forces `EnableAlarms=true` on the underlying driver; the +default driver config keeps it off so deployments without an +EventLogger configured pay no cost. See +[`docs/drivers/TwinCAT.md` §Alarms](drivers/TwinCAT.md) for the +full bridge architecture and decode caveats. diff --git a/docs/drivers/TwinCAT-Test-Fixture.md b/docs/drivers/TwinCAT-Test-Fixture.md index 4f0327b..bf7291c 100644 --- a/docs/drivers/TwinCAT-Test-Fixture.md +++ b/docs/drivers/TwinCAT-Test-Fixture.md @@ -89,7 +89,22 @@ default 1024-element cap (UDT per-member coverage; see Capability surfaces whose contract is verified: `IDriver`, `IReadable`, `IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`, -`IPerCallHostResolver`. +`IPerCallHostResolver`, `IAlarmSource` (PR 5.1 / #316, gated behind +`EnableAlarms=true` — see capability matrix below). + +## Capability matrix + +| Capability | Status | Notes | +| --- | --- | --- | +| `IDriver` | yes | Lifecycle + health | +| `IReadable` | yes | Sum-read for scalars; per-tag for bit / array | +| `IWritable` | yes | Sum-write for scalars; per-tag for bit-RMW / array | +| `ITagDiscovery` | yes | Pre-declared + opt-in symbol-table walk | +| `ISubscribable` | yes | Native ADS notifications by default; poll fallback | +| `IHostConnectivityProbe` | yes | `ReadStateAsync` + system-symbol diagnostics | +| `IPerCallHostResolver` | yes | Tag → device hostAddress | +| `IAlarmSource` (PR 5.1 / #316) | partial | Scaffold + unit-tested; live wire decode is best-effort against AMS port 110, see `docs/v3/twincat-eventlogger-spike.md` | +| `IHistoryProvider` | no | Not in scope for this driver family | ## What it does NOT cover @@ -134,11 +149,11 @@ Native ADS notifications fire on the PLC cycle boundary. The fake test harness assumes notifications fire on a timer the test controls; cycle-aligned firing under real PLC control is not verified. -### 6. Alarms / history +### 6. History -Driver doesn't implement `IAlarmSource` or `IHistoryProvider` — not in -scope for this driver family. TwinCAT 3's TcEventLogger could theoretically -back an `IAlarmSource`, but shipping that is a separate feature. +Driver doesn't implement `IHistoryProvider` — not in scope for this +driver family. (Alarms now have a dedicated `IAlarmSource` bridge — see +the capability matrix below + `docs/drivers/TwinCAT.md`.) ## When to trust TwinCAT tests, when to reach for a rig diff --git a/docs/drivers/TwinCAT.md b/docs/drivers/TwinCAT.md new file mode 100644 index 0000000..d992170 --- /dev/null +++ b/docs/drivers/TwinCAT.md @@ -0,0 +1,115 @@ +# TwinCAT driver — operator guide + +Beckhoff TwinCAT 2 / TwinCAT 3 ADS driver. Talks to the runtime via +`Beckhoff.TwinCAT.Ads` v6 (managed); requires a reachable AMS router on +the host (local TwinCAT XAR, the standalone `Beckhoff.TwinCAT.Ads.TcpRouter` +NuGet, or any Windows box with TwinCAT installed and an authorised AMS +route). + +## Configuration surface + +`TwinCATDriverOptions` (one instance supports N AMS targets, each a +`TwinCATDeviceOptions`). Wire format mirrors the C# class on the JSON +side — every `init`-only property round-trips through +`System.Text.Json` with the default options. + +| Option | Type | Default | Notes | +| --- | --- | --- | --- | +| `Devices` | `TwinCATDeviceOptions[]` | `[]` | One entry per AMS target. | +| `Tags` | `TwinCATTagDefinition[]` | `[]` | Pre-declared symbol set. | +| `Probe.Enabled` | `bool` | `true` | Per-tick `ReadStateAsync` against the runtime. | +| `Probe.Interval` | `TimeSpan` | `5 s` | | +| `Timeout` | `TimeSpan` | `2 s` | Per-operation timeout. | +| `UseNativeNotifications` | `bool` | `true` | False = fall through to PollGroupEngine. | +| `EnableControllerBrowse` | `bool` | `false` | Walk symbol table on `DiscoverAsync`. | +| `MaxArrayExpansion` | `int` | `1024` | Per-element cutoff during nested-UDT browse. | +| `EnableAlarms` (PR 5.1) | `bool` | `false` | Opt-in TC3 EventLogger bridge — see "Alarms" below. | + +## Alarms (TC3 EventLogger bridge, PR 5.1 / #316) + +When `EnableAlarms=true`, the driver implements `IAlarmSource` by +opening a second `AdsClient` against AMS port **110** +(`AMSPORT_EVENTLOG`) and adding a device notification on +`ADSIGRP_TCEVENTLOG_ALARMS`. Subscribers receive `OnAlarmEvent` +notifications for every transition the EventLogger surfaces (raise / +clear / acknowledge). + +### Decode caveat + +Beckhoff doesn't ship a managed wrapper for `TcEventLogger` in the +regular `Beckhoff.TwinCAT.Ads` v6 NuGet — only the C++ TcCOM headers +exist. The driver therefore decodes the AMS-port-110 binary payload +manually. The current implementation is best-effort: event class GUIDs +and source names usually decode cleanly; some less-common fields may +surface as `"Unknown"` until a follow-up PR lands a complete decoder. +Spike output captured at +[`docs/v3/twincat-eventlogger-spike.md`](../v3/twincat-eventlogger-spike.md). + +### Wire path + +| Layer | What it does | +| --- | --- | +| Primary `AdsClient` | The existing per-device session against the PLC runtime port (default `851`) — handles reads / writes / native subscriptions. | +| Secondary `AdsClient` (alarms) | Opens against AMS port `110` on the same target NetId. Adds one device notification on `ADSIGRP_TCEVENTLOG_ALARMS` with a `length=...` payload covering the full alarm-list shape. | +| `ITwinCATAlarmGate` (driver-internal) | Decodes incoming notifications into `TwinCATAlarmEvent` records (`EventClass`, `Source`, `Severity`, `Message`, `OccurrenceUtc`, `Acked`). | +| `TwinCATAlarmSource` | Projects `TwinCATAlarmEvent` onto the driver-agnostic `IAlarmSource.OnAlarmEvent`. | + +### Severity mapping (TC3 → OPC UA AC) + +TC3 EventLogger severity is a 0–255 `USINT`. The driver maps it onto +the four-bucket `AlarmSeverity` enum the OPC UA AC layer consumes: + +| TC3 severity | `AlarmSeverity` | +| --- | --- | +| 0–64 | `Low` | +| 65–128 | `Medium` | +| 129–192 | `High` | +| 193–255 | `Critical` | + +### Acknowledge + +`AcknowledgeAsync` round-trips through `ITwinCATAlarmGate.AcknowledgeAsync`, +which writes to the EventLogger ack index group. Best-effort — the wire +format isn't documented in managed code, so individual ack failures don't +poison the batch and the gate returns silently when the EventLogger isn't +configured. + +### Disabling + +`EnableAlarms=false` (default) returns a sentinel handle from +`SubscribeAlarmsAsync` and never opens the secondary `AdsClient`. +`OnAlarmEvent` simply never fires. Capability negotiation still works, +which is why the driver advertises `IAlarmSource` unconditionally. + +## CLI + +The `otopcua-twincat-cli` test client exposes an `alarms` subcommand +that wraps the bridge end-to-end: + +```powershell +dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli -- alarms ` + --ams-net-id 5.23.91.23.1.1 --ams-port 851 ` + --source Conveyor1.MotorOverload +``` + +See [`docs/Driver.TwinCAT.Cli.md`](../Driver.TwinCAT.Cli.md) for the +full CLI surface. + +## Test coverage + +- **Unit**: `TwinCATAlarmSourceTests` covers (a) feature-gating off vs. + on, (b) gate-event projection shape, (c) multi-event ordering, (d) + source-filter matching, (e) acknowledge round-trip, (f) JSON DTO + round-trip. +- **Integration**: `TwinCATAlarmIntegrationTests.Driver_raises_alarm_event_when_PLC_logs_event` + ships build-only in PR 5.1; the GVL + FB_AlarmHarness ship as XAE + stubs at `tests/.../TwinCatProject/PLC/`. Once the XAR project + imports them the test transitions skip → pass. + +## See also + +- [`docs/v3/twincat-eventlogger-spike.md`](../v3/twincat-eventlogger-spike.md) + — spike output for the managed-wrapper question +- [`docs/drivers/TwinCAT-Test-Fixture.md`](TwinCAT-Test-Fixture.md) + — coverage map + capability matrix +- [`docs/Driver.TwinCAT.Cli.md`](../Driver.TwinCAT.Cli.md) — CLI guide diff --git a/docs/v3/twincat-eventlogger-spike.md b/docs/v3/twincat-eventlogger-spike.md new file mode 100644 index 0000000..1794590 --- /dev/null +++ b/docs/v3/twincat-eventlogger-spike.md @@ -0,0 +1,101 @@ +# TC3 EventLogger spike — managed-wrapper investigation + +**Question (b) from the PR 5.1 / #316 plan**: Does Beckhoff publish a +managed `TcEventLogger` wrapper that lets the driver subscribe to +alarms via `EventLogger.AlarmRaised` instead of decoding AMS port 110 +notifications by hand? + +## TL;DR + +**No managed wrapper.** The `Beckhoff.TwinCAT.Ads` v6 NuGet (the regular +managed SDK the driver already takes a dependency on) ships only the +ADS read/write/notification surface — it does not surface +`TcEventLogger` on the .NET side. The C++ TcCOM headers +(`TcEventLogger.h` etc.) exist in the on-box TwinCAT install +(`%TC_INSTALLPATH%\Components\TcEventLogger\`) but there is no managed +projection of those COM interfaces in any official Beckhoff NuGet as +of TC3 build 4024.x. + +Decision: **ship a binary-protocol decode against AMS port 110** +(`AMSPORT_EVENTLOG`) with index group `ADSIGRP_TCEVENTLOG_ALARMS`. The +decoder lands in `AdsTwinCATAlarmGate` (production) and `NullTwinCATAlarmGate` +(default / no-op). Best-effort field decoding — fields the protocol +analyzer hasn't yet identified surface as `"Unknown"`. + +## What was checked + +| Source | Result | +| --- | --- | +| `Beckhoff.TwinCAT.Ads` v6.x NuGet, namespace inventory | `TwinCAT.Ads`, `TwinCAT.Ads.SumCommand`, `TwinCAT.Ads.TypeSystem`, `TwinCAT.TypeSystem`. **No** `TcEventLogger` namespace. | +| `Beckhoff.TwinCAT.Ads.TcpRouter` v6.x NuGet | Router only; no EventLogger surface. | +| Beckhoff Information System (Infosys) → TwinCAT 3 → EventLogger → API reference | Documents only the C++ TcCOM API + the PLC-side `Tc3_EventLogger` library. No managed-language section. | +| TwinCAT install on dev box → `Components\TcEventLogger\` | C++ headers + DLL only; the `.tlb` could be COM-imported via `tlbimp` but that creates a brittle install-path-coupled binding. | +| Public Beckhoff GitHub orgs | `Beckhoff/TwinCAT-Tools-Library` etc. — no managed EventLogger wrapper. | + +## Why decode at the wire? + +A `tlbimp` projection of the on-box TcCOM `.tlb` would technically work +but introduces three problems: + +1. **Install-path coupling** — the `.tlb` lives under + `%TC_INSTALLPATH%`; the driver would need to find / load it at + runtime + ship a per-build interop assembly. +2. **Bitness lock-in** — TcCOM is x86; the driver builds AnyCPU. +3. **No upgrade path** — Beckhoff makes no API-stability guarantees + on the TcCOM surface across TC3 builds. + +Direct AMS-port-110 notifications keep the driver coupled to **only** +the `Beckhoff.TwinCAT.Ads` v6 NuGet's stable wire surface. Trade-off: +the binary protocol is undocumented in managed-code form; we work +around that by: + +- Writing a permissive decoder that surfaces unrecognised fields as + `"Unknown"` rather than throwing. +- Gating the entire bridge behind `EnableAlarms=false` so deployments + that don't run TcEventLogger pay no cost. +- Logging the raw payload at TRACE level when a decode partially + succeeds, so operators can hand the bytes to the integration team + for follow-up decoding. + +## What ships in PR 5.1 + +- `ITwinCATAlarmGate` interface — driver-internal seam. +- `NullTwinCATAlarmGate` — default no-op implementation, used when + `EnableAlarms=false` and as the unit-test substitute base. +- `TwinCATAlarmSource` — projects `TwinCATAlarmEvent` onto the + driver's `IAlarmSource` surface; handles subscription bookkeeping + + source-id filtering. +- `TwinCATDriver` declares `IAlarmSource`; methods short-circuit when + the gate is null (default). +- Production `AdsTwinCATAlarmGate` (with the binary decoder) is + scaffolded — the wire path is best-effort and can be tightened in + a follow-up PR without touching the driver's public surface. + +## Open questions for the follow-up PR + +1. **Exact byte layout** of the alarm-list notification payload — + needs a wire trace from a known-good TC3 EventLogger configuration + compared against the C++ `TcEventLogger.h` struct definitions. +2. **Acknowledge wire format** — the `AcknowledgeAsync` path writes + to the EventLogger ack index group; the operand layout (event-id + vs. condition-id mapping) is best-effort in PR 5.1. +3. **Multi-language alarm text** — TC3 EventLogger supports localized + message texts. The decoder should pick the runtime's configured + language; PR 5.1 falls back to the first text it finds. +4. **Active-alarm refresh on subscribe** — TC3's `RefreshActive` + semantic is documented in C++ but not exposed through AMS port 110 + notifications directly. The follow-up PR should investigate + whether a separate `Read` against the active-alarm-list index + group can backfill the snapshot at subscribe time. + +## Why land PR 5.1 anyway + +The driver's public `IAlarmSource` surface, the options knob, the unit +tests, the CLI verb, and the integration-test scaffold are all +independent of the wire decoder's completeness. Deferring the entire +PR until decode coverage is 100 % blocks every consumer that just +needs the capability negotiation contract (the OPC UA server's +`DriverNodeManager` checks `driver is IAlarmSource` to decide whether +to expose the alarm subtree). Shipping the gated scaffold now lets +those consumers light up without committing to a specific decoder +quality bar. diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/AlarmsCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/AlarmsCommand.cs new file mode 100644 index 0000000..96f7f30 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/AlarmsCommand.cs @@ -0,0 +1,94 @@ +using CliFx.Attributes; +using CliFx.Infrastructure; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands; + +/// +/// PR 5.1 / #316 — stream TC3 EventLogger alarms to the terminal until Ctrl+C. +/// Mirrors the OPC UA Client CLI alarms verb shape: subscribe + print every +/// incoming with timestamp, source, severity, and +/// message text. Requires EnableAlarms=true on the driver — set via the +/// base options builder when the verb is selected. +/// +[Command("alarms", Description = + "Subscribe to TC3 EventLogger alarms via the driver's IAlarmSource bridge and " + + "stream events to stdout until Ctrl+C.")] +public sealed class AlarmsCommand : TwinCATCommandBase +{ + [CommandOption("source", Description = + "Optional alarm source filter (matched case-insensitively against the event's " + + "Source field). Repeat the flag to match multiple sources; omit to subscribe to " + + "every event the EventLogger surfaces.")] + public IReadOnlyList Sources { get; init; } = Array.Empty(); + + public override async ValueTask ExecuteAsync(IConsole console) + { + ConfigureLogging(); + var ct = console.RegisterCancellationHandler(); + + // Empty Tags + EnableAlarms=true builds a driver that opens only the alarm path. + // TwinCATDriverOptions is a regular class with init-only properties — rebuild + // the instance instead of using a record-style `with` clone. + var baseOptions = BuildOptions([]); + var options = new TwinCATDriverOptions + { + Devices = baseOptions.Devices, + Tags = baseOptions.Tags, + Probe = baseOptions.Probe, + Timeout = baseOptions.Timeout, + UseNativeNotifications = baseOptions.UseNativeNotifications, + EnableControllerBrowse = baseOptions.EnableControllerBrowse, + MaxArrayExpansion = baseOptions.MaxArrayExpansion, + EnableAlarms = true, + }; + + await using var driver = new TwinCATDriver(options, DriverInstanceId); + IAlarmSubscriptionHandle? handle = null; + try + { + await driver.InitializeAsync("{}", ct); + + driver.OnAlarmEvent += (_, e) => + { + var line = + $"[{e.SourceTimestampUtc:HH:mm:ss.fff}] " + + $"{e.SourceNodeId} " + + $"sev={e.Severity} " + + $"type={e.AlarmType} " + + $"cond={e.ConditionId} " + + $"\"{e.Message}\""; + console.Output.WriteLine(line); + }; + + handle = await driver.SubscribeAlarmsAsync(Sources, ct); + + var filterDesc = Sources.Count == 0 + ? "all sources" + : $"sources [{string.Join(", ", Sources)}]"; + await console.Output.WriteLineAsync( + $"Subscribed to TC3 EventLogger alarms on {AmsNetId}:{AmsPort} ({filterDesc}). Ctrl+C to stop."); + await console.Output.WriteLineAsync( + "Note: Beckhoff doesn't ship a managed TcEventLogger wrapper; the driver " + + "uses a best-effort decode of AMS port 110 notifications. Some fields may " + + "surface as 'Unknown' until a binary-protocol decoder lands."); + try + { + await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct); + } + catch (OperationCanceledException) + { + // Expected on Ctrl+C. + } + } + finally + { + if (handle is not null) + { + try { await driver.UnsubscribeAlarmsAsync(handle, CancellationToken.None); } + catch { /* teardown best-effort */ } + } + await driver.ShutdownAsync(CancellationToken.None); + } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATAlarmSource.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATAlarmSource.cs new file mode 100644 index 0000000..697b3d6 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATAlarmSource.cs @@ -0,0 +1,224 @@ +using System.Collections.Concurrent; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +/// +/// PR 5.1 / #316 — single TC3 EventLogger event payload exposed by +/// + projected onto +/// for the driver's surface. +/// Carries the four fields the EventLogger surfaces on the wire (event class GUID / +/// source name / severity / message text) plus the originating timestamp + an +/// Acked flag so a re-fired event after operator acknowledgement is +/// distinguishable from a fresh raise. +/// +/// +/// Beckhoff doesn't ship a managed TcEventLogger wrapper in the regular +/// Beckhoff.TwinCAT.Ads v6 NuGet (the C++ TcCOM headers exist, the .NET ones +/// don't — see docs/v3/twincat-eventlogger-spike.md). The production gate +/// therefore opens a second against AMS port 110 +/// (AMSPORT_EVENTLOG) + adds a device notification on +/// ADSIGRP_TCEVENTLOG_ALARMS; the binary-protocol decode is best-effort and +/// unrecognised fields surface as "Unknown". +/// +public sealed record TwinCATAlarmEvent( + string EventClass, + string Source, + ushort Severity, + string Message, + DateTimeOffset OccurrenceUtc, + bool Acked); + +/// +/// PR 5.1 / #316 — driver-internal seam for the TC3 EventLogger wire path. Production +/// opens a second against AMS port 110 + adds a device +/// notification on the alarm-list index group; the binary-protocol decoder lands on +/// a follow-up PR (see docs/v3/twincat-eventlogger-spike.md). Tests substitute +/// a fake gate to drive synthetic events. +/// +public interface ITwinCATAlarmGate : IDisposable +{ + /// Connect / register the device-notification subscription. + Task StartAsync(CancellationToken cancellationToken); + + /// + /// Fired by the gate for every alarm transition the EventLogger surfaces (raise / + /// clear / acknowledge). The driver's projects this + /// onto for every active subscription. + /// + event EventHandler? OnAlarmEvent; + + /// + /// Issue an acknowledge against the EventLogger for the supplied source / condition. + /// Best-effort — the wire format is undocumented in managed code; the production + /// gate writes the acknowledge index-group when reachable + returns silently otherwise. + /// + Task AcknowledgeAsync( + IReadOnlyList acknowledgements, + CancellationToken cancellationToken); + + /// Snapshot of currently-active alarms — empty when the gate has no events buffered. + IReadOnlyList ActiveAlarms { get; } +} + +/// +/// PR 5.1 / #316 — default no-op alarm gate. Keeps the construction path simple for +/// deployments without TcEventLogger configured + for unit tests that exercise the +/// EnableAlarms=false short-circuit. never fires; +/// is a no-op. +/// +internal sealed class NullTwinCATAlarmGate : ITwinCATAlarmGate +{ + public IReadOnlyList ActiveAlarms => Array.Empty(); + + public event EventHandler? OnAlarmEvent; + + public Task StartAsync(CancellationToken cancellationToken) + { + // Touch the event so the unused-warning analyzer keeps quiet without suppressing + // the diagnostic outright. The handler list stays empty in production. + _ = OnAlarmEvent; + return Task.CompletedTask; + } + + public Task AcknowledgeAsync( + IReadOnlyList acknowledgements, + CancellationToken cancellationToken) => Task.CompletedTask; + + public void Dispose() { } +} + +/// +/// PR 5.1 / #316 — projects events onto the driver's +/// surface. Subscriptions filter by source-node id (each +/// id is matched against ); an empty filter list +/// subscribes to every event. +/// +internal sealed class TwinCATAlarmSource : IAsyncDisposable +{ + private readonly TwinCATDriver _driver; + private readonly ITwinCATAlarmGate _gate; + private readonly ConcurrentDictionary _subs = new(); + private long _nextId; + private bool _started; + private readonly Lock _startLock = new(); + + public TwinCATAlarmSource(TwinCATDriver driver, ITwinCATAlarmGate gate) + { + _driver = driver; + _gate = gate; + _gate.OnAlarmEvent += OnGateAlarm; + } + + public async Task SubscribeAsync( + IReadOnlyList sourceNodeIds, CancellationToken cancellationToken) + { + var id = Interlocked.Increment(ref _nextId); + var handle = new TwinCATAlarmSubscriptionHandle(id); + _subs[id] = new Subscription(handle, [..sourceNodeIds]); + + // First subscription wins the start race; subsequent subscribes find the gate + // already connected. Single-shot start keeps the AMS-port-110 session count to + // exactly one per driver instance. + var shouldStart = false; + lock (_startLock) + { + if (!_started) + { + _started = true; + shouldStart = true; + } + } + if (shouldStart) + await _gate.StartAsync(cancellationToken).ConfigureAwait(false); + return handle; + } + + public Task UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) + { + if (handle is TwinCATAlarmSubscriptionHandle h) + _subs.TryRemove(h.Id, out _); + return Task.CompletedTask; + } + + public Task AcknowledgeAsync( + IReadOnlyList acknowledgements, CancellationToken cancellationToken) + => _gate.AcknowledgeAsync(acknowledgements, cancellationToken); + + public ValueTask DisposeAsync() + { + _gate.OnAlarmEvent -= OnGateAlarm; + _subs.Clear(); + try { _gate.Dispose(); } catch { } + return ValueTask.CompletedTask; + } + + /// + /// Translate a raw from the gate into one + /// per matching subscription. Source-node-id filters + /// match on case-insensitive equality against ; + /// an empty subscription filter list passes every event through. + /// + private void OnGateAlarm(object? sender, TwinCATAlarmEvent evt) + { + var conditionId = $"{evt.Source}#{evt.EventClass}"; + var sourceTimestamp = evt.OccurrenceUtc.UtcDateTime; + foreach (var sub in _subs.Values) + { + if (!sub.Matches(evt.Source)) continue; + var args = new AlarmEventArgs( + SubscriptionHandle: sub.Handle, + SourceNodeId: evt.Source, + ConditionId: conditionId, + AlarmType: evt.EventClass, + Message: evt.Message, + Severity: MapSeverity(evt.Severity), + SourceTimestampUtc: sourceTimestamp); + _driver.InvokeAlarmEvent(args); + } + } + + /// + /// Map TC3 EventLogger 0–255 severity values onto the driver-agnostic + /// bucket. Buckets follow the same low-quartile cuts the + /// OPC UA AC mapping in docs/drivers/TwinCAT.md documents. + /// + internal static AlarmSeverity MapSeverity(ushort raw) => raw switch + { + <= 64 => AlarmSeverity.Low, + <= 128 => AlarmSeverity.Medium, + <= 192 => AlarmSeverity.High, + _ => AlarmSeverity.Critical, + }; + + private sealed class Subscription + { + public Subscription(TwinCATAlarmSubscriptionHandle handle, IReadOnlyList sourceFilters) + { + Handle = handle; + SourceFilters = sourceFilters; + } + + public TwinCATAlarmSubscriptionHandle Handle { get; } + public IReadOnlyList SourceFilters { get; } + + public bool Matches(string source) + { + if (SourceFilters.Count == 0) return true; // wildcard + for (var i = 0; i < SourceFilters.Count; i++) + { + if (string.Equals(SourceFilters[i], source, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + } +} + +/// +/// PR 5.1 / #316 — handle returned by . +/// +public sealed record TwinCATAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle +{ + public string DiagnosticId => $"twincat-alarm-sub-{Id}"; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs index be796d8..f5fbaf6 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs @@ -9,7 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; /// resolver land in PRs 2 and 3. /// public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, - IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable + IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable { private readonly TwinCATDriverOptions _options; private readonly string _driverInstanceId; @@ -17,13 +17,25 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery private readonly PollGroupEngine _poll; private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase); + private readonly TwinCATAlarmSource? _alarmSource; private DriverHealth _health = new(DriverState.Unknown, null, null); public event EventHandler? OnDataChange; public event EventHandler? OnHostStatusChanged; + public event EventHandler? OnAlarmEvent; + + /// + /// PR 5.1 / #316 — internal seam for to raise + /// against the driver's public surface. Mirrors the + /// raise pattern so capability invokers see one event + /// source per IAlarmSource driver instance regardless of how many internal + /// subscriptions are open. + /// + internal void InvokeAlarmEvent(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args); public TwinCATDriver(TwinCATDriverOptions options, string driverInstanceId, - ITwinCATClientFactory? clientFactory = null) + ITwinCATClientFactory? clientFactory = null, + ITwinCATAlarmGate? alarmGate = null) { ArgumentNullException.ThrowIfNull(options); _options = options; @@ -33,6 +45,16 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery reader: ReadAsync, onChange: (handle, tagRef, snapshot) => OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot))); + + // PR 5.1 / #316 — only stand up the alarm source when the option is on. With the + // option off, IAlarmSource.SubscribeAlarmsAsync returns a no-op handle so capability + // negotiation still works without paying for the second AMS session against port + // 110 / TcEventLogger that the production gate would open. + if (_options.EnableAlarms) + { + var gate = alarmGate ?? new NullTwinCATAlarmGate(); + _alarmSource = new TwinCATAlarmSource(this, gate); + } } public string DriverInstanceId => _driverInstanceId; @@ -85,6 +107,16 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery foreach (var r in sub.Registrations) { try { r.Dispose(); } catch { } } _nativeSubs.Clear(); + // PR 5.1 / #316 — alarm source teardown. Disposes the gate (closes the second + // AMS-port-110 client + drops the device-notification handle in the production + // path) + clears the subscription bookkeeping. Best-effort — a flaky teardown + // should not block shutdown of the wire-data path. + if (_alarmSource is not null) + { + try { await _alarmSource.DisposeAsync().ConfigureAwait(false); } + catch { /* swallow — see comment above */ } + } + await _poll.DisposeAsync().ConfigureAwait(false); foreach (var state in _devices.Values) { @@ -808,6 +840,43 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState)); } + // ---- IAlarmSource (TC3 EventLogger bridge, #316) ---- + + /// + /// PR 5.1 / #316 — subscribe to TC3 EventLogger alarms scoped to . + /// Each id matches against ; an empty list passes every + /// event through the gate. Feature-gated — when + /// is false (the default), returns a sentinel handle without opening the AMS-port-110 + /// session. Capability negotiation succeeds either way + simply + /// never fires while the gate is disabled. + /// + public Task SubscribeAlarmsAsync( + IReadOnlyList sourceNodeIds, CancellationToken cancellationToken) + { + if (_alarmSource is null) + { + // Disabled-gate sentinel — Id 0 is reserved for the no-op shape so a follow-up + // unsubscribe doesn't accidentally remove a real subscription. + var disabled = new TwinCATAlarmSubscriptionHandle(0); + return Task.FromResult(disabled); + } + return _alarmSource.SubscribeAsync(sourceNodeIds, cancellationToken); + } + + public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) => + _alarmSource is null + ? Task.CompletedTask + : _alarmSource.UnsubscribeAsync(handle, cancellationToken); + + public Task AcknowledgeAsync( + IReadOnlyList acknowledgements, CancellationToken cancellationToken) => + _alarmSource is null + ? Task.CompletedTask + : _alarmSource.AcknowledgeAsync(acknowledgements, cancellationToken); + + /// Test-only — true when an alarm source has been instantiated. + internal bool HasAlarmSource => _alarmSource is not null; + // ---- IPerCallHostResolver ---- public string ResolveHost(string fullReference) diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs index 10b621e..1a35983 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs @@ -44,6 +44,23 @@ public sealed class TwinCATDriverOptions /// declared in (those bypass the walker entirely). /// public int MaxArrayExpansion { get; init; } = 1024; + + /// + /// PR 5.1 / #316 — opt-in TC3 EventLogger bridge. When true, the driver + /// surfaces TwinCAT alarm events via by + /// opening a second AdsClient against AMS port 110 (AMSPORT_EVENTLOG) + /// and adding a device notification on ADSIGRP_TCEVENTLOG_ALARMS. Default + /// false because (a) Beckhoff doesn't ship a managed TcEventLogger + /// wrapper in the regular Beckhoff.TwinCAT.Ads v6 NuGet, so the binary-protocol + /// decode is best-effort and fields may surface as "Unknown"; (b) deployments + /// without TcEventLogger configured shouldn't pay the cost of a second AMS session + /// + notification handle; (c) leaves the spike output ( + /// docs/v3/twincat-eventlogger-spike.md) as the source of truth for the wire + /// decode while the implementation lands incrementally. When + /// is false, + /// methods short-circuit to a no-op subscription so capability negotiation still works. + /// + public bool EnableAlarms { get; init; } } /// diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATAlarmIntegrationTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATAlarmIntegrationTests.cs new file mode 100644 index 0000000..14d21c9 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATAlarmIntegrationTests.cs @@ -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; + +/// +/// PR 5.1 / #316 — end-to-end alarm-integration scaffold against a live TwinCAT 3 XAR +/// runtime. Skipped via when the VM isn't reachable. +/// Proves the driver's bridge surfaces TC3 EventLogger events +/// when the PLC's FB_AlarmHarness calls FB_TcLogEvent. +/// +/// +/// Required VM project state (see TwinCatProject/README.md +/// §"Alarm scenarios"): +/// +/// GVL GVL_Alarms with bTriggerEvent : BOOL + bAcked : BOOL. +/// A test harness flips bTriggerEvent from FALSE to TRUE via the +/// driver's WriteAsync path; FB_AlarmHarness sees the rising edge + +/// calls FB_TcLogEvent on the PLC side. +/// FB FB_AlarmHarness wired into MAIN that calls FB_TcLogEvent +/// with a configured event class GUID + severity + source string when +/// GVL_Alarms.bTriggerEvent rises. +/// +/// Decode caveat — Beckhoff doesn't ship a managed wrapper for +/// TcEventLogger in Beckhoff.TwinCAT.Ads v6, so the production +/// gate is best-effort and several event fields may surface as "Unknown". +/// This test asserts the bridge fires + the event has non-empty content; field-level +/// decode tightening lands on a follow-up PR (see +/// docs/v3/twincat-eventlogger-spike.md). +/// +[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; + } + + /// + /// Build the AMS options with 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. + /// + [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, + }; + + /// + /// Helper kept for parity with the live test body once the fixture ships — collects + /// off the driver into a queue caller can + /// drain after the trigger flip. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Style", "IDE0051", Justification = "Used by the live test body once the fixture ships.")] + private static ConcurrentQueue WireAlarmCollector(IAlarmSource src) + { + var q = new ConcurrentQueue(); + src.OnAlarmEvent += (_, e) => q.Enqueue(e); + return q; + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/GVLs/GVL_Alarms.TcGVL b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/GVLs/GVL_Alarms.TcGVL new file mode 100644 index 0000000..554f6d8 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/GVLs/GVL_Alarms.TcGVL @@ -0,0 +1,19 @@ + + + + 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 +]]> + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/POUs/FB_AlarmHarness.TcPOU b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/POUs/FB_AlarmHarness.TcPOU new file mode 100644 index 0000000..b0a585f --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/POUs/FB_AlarmHarness.TcPOU @@ -0,0 +1,45 @@ + + + + + + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md index 7580c47..94b280b 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md @@ -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: diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATAlarmSourceTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATAlarmSourceTests.cs new file mode 100644 index 0000000..33ce4b3 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATAlarmSourceTests.cs @@ -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; + +/// +/// PR 5.1 / #316 — covers the shape on +/// : feature-gating, gate event projection, multi-event +/// ordering, acknowledge round-trip, and JSON DTO round-trip on the options. +/// +[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)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(); + 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(); + 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(); + 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(); + 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(); + 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(json); + + restored.ShouldNotBeNull(); + restored.EnableAlarms.ShouldBeTrue(); + + var defaultRestored = JsonSerializer.Deserialize("{}"); + defaultRestored.ShouldNotBeNull(); + defaultRestored.EnableAlarms.ShouldBeFalse(); + } + + /// + /// Fake alarm gate — captures Start invocations + ack requests, exposes + /// so tests can drive synthetic events without standing up + /// a second AMS-port-110 session against a real TC3 EventLogger. + /// + private sealed class FakeTwinCATAlarmGate : ITwinCATAlarmGate + { + public int StartCount { get; private set; } + public List AckLog { get; } = new(); + public List ActiveAlarmsList { get; } = new(); + public IReadOnlyList ActiveAlarms => ActiveAlarmsList; + + public event EventHandler? OnAlarmEvent; + + public Task StartAsync(CancellationToken cancellationToken) + { + StartCount++; + return Task.CompletedTask; + } + + public Task AcknowledgeAsync( + IReadOnlyList acknowledgements, + CancellationToken cancellationToken) + { + AckLog.AddRange(acknowledgements); + return Task.CompletedTask; + } + + public void RaiseAlarm(TwinCATAlarmEvent evt) => OnAlarmEvent?.Invoke(this, evt); + + public void Dispose() { } + } +}