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

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

View File

@@ -0,0 +1,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);
}
}
}

View 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 0255 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}";
}

View File

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

View File

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