Auto: twincat-5.1 — IAlarmSource via TC3 EventLogger (gated, scaffold)
Closes #316
This commit is contained in:
@@ -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] <source> sev=<Low|Medium|High|Critical> type=<event-class> cond=<condition-id> "<message>"
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
115
docs/drivers/TwinCAT.md
Normal file
115
docs/drivers/TwinCAT.md
Normal file
@@ -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
|
||||
101
docs/v3/twincat-eventlogger-spike.md
Normal file
101
docs/v3/twincat-eventlogger-spike.md
Normal file
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// PR 5.1 / #316 — stream TC3 EventLogger alarms to the terminal until Ctrl+C.
|
||||
/// Mirrors the OPC UA Client CLI <c>alarms</c> verb shape: subscribe + print every
|
||||
/// incoming <see cref="AlarmEventArgs"/> with timestamp, source, severity, and
|
||||
/// message text. Requires <c>EnableAlarms=true</c> on the driver — set via the
|
||||
/// base options builder when the verb is selected.
|
||||
/// </summary>
|
||||
[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<string> Sources { get; init; } = Array.Empty<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
224
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATAlarmSource.cs
Normal file
224
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATAlarmSource.cs
Normal file
@@ -0,0 +1,224 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// PR 5.1 / #316 — single TC3 EventLogger event payload exposed by
|
||||
/// <see cref="ITwinCATAlarmGate"/> + projected onto
|
||||
/// <see cref="AlarmEventArgs"/> for the driver's <see cref="IAlarmSource"/> surface.
|
||||
/// Carries the four fields the EventLogger surfaces on the wire (event class GUID /
|
||||
/// source name / severity / message text) plus the originating timestamp + an
|
||||
/// <c>Acked</c> flag so a re-fired event after operator acknowledgement is
|
||||
/// distinguishable from a fresh raise.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Beckhoff doesn't ship a managed <c>TcEventLogger</c> wrapper in the regular
|
||||
/// <c>Beckhoff.TwinCAT.Ads</c> v6 NuGet (the C++ TcCOM headers exist, the .NET ones
|
||||
/// don't — see <c>docs/v3/twincat-eventlogger-spike.md</c>). The production gate
|
||||
/// therefore opens a second <see cref="ITwinCATClient"/> against AMS port 110
|
||||
/// (<c>AMSPORT_EVENTLOG</c>) + adds a device notification on
|
||||
/// <c>ADSIGRP_TCEVENTLOG_ALARMS</c>; the binary-protocol decode is best-effort and
|
||||
/// unrecognised fields surface as <c>"Unknown"</c>.
|
||||
/// </remarks>
|
||||
public sealed record TwinCATAlarmEvent(
|
||||
string EventClass,
|
||||
string Source,
|
||||
ushort Severity,
|
||||
string Message,
|
||||
DateTimeOffset OccurrenceUtc,
|
||||
bool Acked);
|
||||
|
||||
/// <summary>
|
||||
/// PR 5.1 / #316 — driver-internal seam for the TC3 EventLogger wire path. Production
|
||||
/// opens a second <see cref="ITwinCATClient"/> 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 <c>docs/v3/twincat-eventlogger-spike.md</c>). Tests substitute
|
||||
/// a fake gate to drive synthetic events.
|
||||
/// </summary>
|
||||
public interface ITwinCATAlarmGate : IDisposable
|
||||
{
|
||||
/// <summary>Connect / register the device-notification subscription.</summary>
|
||||
Task StartAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Fired by the gate for every alarm transition the EventLogger surfaces (raise /
|
||||
/// clear / acknowledge). The driver's <see cref="TwinCATAlarmSource"/> projects this
|
||||
/// onto <see cref="AlarmEventArgs"/> for every active subscription.
|
||||
/// </summary>
|
||||
event EventHandler<TwinCATAlarmEvent>? OnAlarmEvent;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Snapshot of currently-active alarms — empty when the gate has no events buffered.</summary>
|
||||
IReadOnlyList<TwinCATAlarmEvent> ActiveAlarms { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>EnableAlarms=false</c> short-circuit. <see cref="OnAlarmEvent"/> never fires;
|
||||
/// <see cref="AcknowledgeAsync"/> is a no-op.
|
||||
/// </summary>
|
||||
internal sealed class NullTwinCATAlarmGate : ITwinCATAlarmGate
|
||||
{
|
||||
public IReadOnlyList<TwinCATAlarmEvent> ActiveAlarms => Array.Empty<TwinCATAlarmEvent>();
|
||||
|
||||
public event EventHandler<TwinCATAlarmEvent>? 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<AlarmAcknowledgeRequest> acknowledgements,
|
||||
CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR 5.1 / #316 — projects <see cref="ITwinCATAlarmGate"/> events onto the driver's
|
||||
/// <see cref="IAlarmSource"/> surface. Subscriptions filter by source-node id (each
|
||||
/// id is matched against <see cref="TwinCATAlarmEvent.Source"/>); an empty filter list
|
||||
/// subscribes to every event.
|
||||
/// </summary>
|
||||
internal sealed class TwinCATAlarmSource : IAsyncDisposable
|
||||
{
|
||||
private readonly TwinCATDriver _driver;
|
||||
private readonly ITwinCATAlarmGate _gate;
|
||||
private readonly ConcurrentDictionary<long, Subscription> _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<IAlarmSubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> 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<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
||||
=> _gate.AcknowledgeAsync(acknowledgements, cancellationToken);
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_gate.OnAlarmEvent -= OnGateAlarm;
|
||||
_subs.Clear();
|
||||
try { _gate.Dispose(); } catch { }
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Translate a raw <see cref="TwinCATAlarmEvent"/> from the gate into one
|
||||
/// <see cref="AlarmEventArgs"/> per matching subscription. Source-node-id filters
|
||||
/// match on case-insensitive equality against <see cref="TwinCATAlarmEvent.Source"/>;
|
||||
/// an empty subscription filter list passes every event through.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map TC3 EventLogger 0–255 severity values onto the driver-agnostic
|
||||
/// <see cref="AlarmSeverity"/> bucket. Buckets follow the same low-quartile cuts the
|
||||
/// OPC UA AC mapping in <c>docs/drivers/TwinCAT.md</c> documents.
|
||||
/// </summary>
|
||||
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<string> sourceFilters)
|
||||
{
|
||||
Handle = handle;
|
||||
SourceFilters = sourceFilters;
|
||||
}
|
||||
|
||||
public TwinCATAlarmSubscriptionHandle Handle { get; }
|
||||
public IReadOnlyList<string> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR 5.1 / #316 — handle returned by <see cref="TwinCATAlarmSource.SubscribeAsync"/>.
|
||||
/// </summary>
|
||||
public sealed record TwinCATAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => $"twincat-alarm-sub-{Id}";
|
||||
}
|
||||
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
/// resolver land in PRs 2 and 3.
|
||||
/// </summary>
|
||||
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<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, TwinCATTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TwinCATAlarmSource? _alarmSource;
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||
|
||||
/// <summary>
|
||||
/// PR 5.1 / #316 — internal seam for <see cref="TwinCATAlarmSource"/> to raise
|
||||
/// <see cref="OnAlarmEvent"/> against the driver's public surface. Mirrors the
|
||||
/// <see cref="OnDataChange"/> raise pattern so capability invokers see one event
|
||||
/// source per <c>IAlarmSource</c> driver instance regardless of how many internal
|
||||
/// subscriptions are open.
|
||||
/// </summary>
|
||||
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) ----
|
||||
|
||||
/// <summary>
|
||||
/// PR 5.1 / #316 — subscribe to TC3 EventLogger alarms scoped to <paramref name="sourceNodeIds"/>.
|
||||
/// Each id matches against <see cref="TwinCATAlarmEvent.Source"/>; an empty list passes every
|
||||
/// event through the gate. Feature-gated — when <see cref="TwinCATDriverOptions.EnableAlarms"/>
|
||||
/// is <c>false</c> (the default), returns a sentinel handle without opening the AMS-port-110
|
||||
/// session. Capability negotiation succeeds either way + <see cref="OnAlarmEvent"/> simply
|
||||
/// never fires while the gate is disabled.
|
||||
/// </summary>
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> 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<IAlarmSubscriptionHandle>(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<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
|
||||
_alarmSource is null
|
||||
? Task.CompletedTask
|
||||
: _alarmSource.AcknowledgeAsync(acknowledgements, cancellationToken);
|
||||
|
||||
/// <summary>Test-only — <c>true</c> when an alarm source has been instantiated.</summary>
|
||||
internal bool HasAlarmSource => _alarmSource is not null;
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
public string ResolveHost(string fullReference)
|
||||
|
||||
@@ -44,6 +44,23 @@ public sealed class TwinCATDriverOptions
|
||||
/// declared in <see cref="Tags"/> (those bypass the walker entirely).
|
||||
/// </summary>
|
||||
public int MaxArrayExpansion { get; init; } = 1024;
|
||||
|
||||
/// <summary>
|
||||
/// PR 5.1 / #316 — opt-in TC3 EventLogger bridge. When <c>true</c>, the driver
|
||||
/// surfaces TwinCAT alarm events via <see cref="Core.Abstractions.IAlarmSource"/> by
|
||||
/// opening a second <c>AdsClient</c> against AMS port 110 (<c>AMSPORT_EVENTLOG</c>)
|
||||
/// and adding a device notification on <c>ADSIGRP_TCEVENTLOG_ALARMS</c>. Default
|
||||
/// <c>false</c> because (a) Beckhoff doesn't ship a managed <c>TcEventLogger</c>
|
||||
/// wrapper in the regular <c>Beckhoff.TwinCAT.Ads</c> v6 NuGet, so the binary-protocol
|
||||
/// decode is best-effort and fields may surface as <c>"Unknown"</c>; (b) deployments
|
||||
/// without TcEventLogger configured shouldn't pay the cost of a second AMS session
|
||||
/// + notification handle; (c) leaves the spike output (
|
||||
/// <c>docs/v3/twincat-eventlogger-spike.md</c>) as the source of truth for the wire
|
||||
/// decode while the implementation lands incrementally. When
|
||||
/// <see cref="EnableAlarms"/> is <c>false</c>, <see cref="Core.Abstractions.IAlarmSource"/>
|
||||
/// methods short-circuit to a no-op subscription so capability negotiation still works.
|
||||
/// </summary>
|
||||
public bool EnableAlarms { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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