PLC alarms

The optional Dahlke.TwinCAT.Ads.Alarms package tracks a TwinCAT alarm array — an ARRAY[..] OF ST_ErrorEntry whose entries carry sKey, Id, ErrorCode, ErrorType, IsActive, NeedsAck, IsAcked and a PLCTimeStamp. It keeps a live set of outstanding alarms and streams Raised / Acknowledged / Cleared / Reoccurred / Ended transitions; acknowledgement calls the PLC's own acknowledging method over ADS.

dotnet add package Dahlke.TwinCAT.Ads.Alarms
{
  "PlcAlarms": {
    "TextCatalog": "alarms.json",
    "Targets": {
      "plc1": { "SymbolPath": "MAIN.ErrorHandler.aHmiAlarms", "CycleTimeMs": 200, "PlcClock": "Local" }
    }
  }
}

A target absent from PlcAlarms:Targets is simply not monitored — the package is opt-in per target. Every misconfiguration (a plcId with no matching entry under PlcTargets, a missing SymbolPath, a non-positive CycleTimeMs) is reported at startup, all at once. A relative TextCatalog resolves against the host's content root, so "alarms.json" means the file next to appsettings.json whatever the working directory happens to be. The full option list is in the configuration reference.

builder.Services
    .AddTwinCatAds(builder.Configuration)
    .AddTwinCatAdsAlarms(builder.Configuration)
    .AddAlarmHandler<PagerNotifier>();

var app = builder.Build();
var monitor = app.Services.GetRequiredService<IPlcAlarmMonitor>();

await app.StartAsync();

// Only meaningful once the host is running. Before StartAsync nothing is subscribed, so
// GetOutstanding() is empty and AcknowledgeAsync finds no such alarm and returns false.
foreach (var alarm in monitor.GetOutstanding())
    Console.WriteLine($"{alarm.Key} ({alarm.Severity}): {alarm.Text}");

await monitor.AcknowledgeAsync("plc1", "BMK1Err404");

await app.WaitForShutdownAsync();

sealed class PagerNotifier(ILogger<PagerNotifier> log) : IPlcAlarmHandler
{
    public Task OnTransitionAsync(AlarmTransition t, CancellationToken ct)
    {
        log.LogWarning("{Kind}: {Key}", t.Kind, t.Alarm.Key);
        return Task.CompletedTask;
    }
}

Handlers

Register handlers with AddAlarmHandler, not AlarmChanged. The monitor is a hosted service: it registers its subscriptions during startup, and ADS delivers one notification per subscription as that subscription is registered — so the whole outstanding set arrives inside StartAsync. An AlarmChanged handler therefore has to be attached before the host starts, which DI cannot express and which fails silently when you get it wrong: every alarm the PLC was already holding at boot simply never reaches you. A handler registered with AddAlarmHandler is resolved as the first step of the monitor's own startup, before any subscription exists, so being too late is not a mistake it is possible to make. AlarmChanged remains for consumers who want the event and will attach it before StartAsync.

IPlcAlarmHandler.OnTransitionAsync returns a Task, but that does not mean it returns immediately: the monitor awaits it before publishing anything else, which is what buys per-target ordering. The contract is the same as for AlarmChanged — be quick and hand the work off. What the Task buys is that a handler with genuinely asynchronous work no longer has to write async void, whose exceptions escape the monitor's isolation after the first await. The CancellationToken is cancelled at shutdown; pass it along to anything slow. Handlers are singletons — open a scope with IServiceScopeFactory if a transition needs scoped services — and are isolated from one another exactly as AlarmChanged handlers are.

The alarm model

An alarm is outstanding while its fault is present OR it still awaits acknowledgement. A PLC alarm array is fixed-size with permanent slots — an alarm ends by IsActive := FALSE, never by leaving the array — and when NeedsAck is set the entry outlives its fault condition until an operator acknowledges it. So a fault that self-clears before anyone looks still reaches you, Cleared marks the fault ending while the alarm stays outstanding, and Ended fires only once the alarm is genuinely finished.

Identity is sKey, not Id. Id names the equipment (BMK) and is shared by every alarm on that machine; sKey is the PLC's own composite key combining the equipment identifier and the error code — Test_Err_60 for equipment Test, error code 60, for example. The exact spelling is the PLC program's business and this package treats it as opaque: never parse it. Group by EquipmentId, key by Key.

Acknowledgement

Acknowledgement is a method call on the PLC, not a write to the entry. AcknowledgeAsync finds the alarm by Key among the outstanding set, then calls the PLC method named by AcknowledgeMethod (default AcknowledgeAlarm) on the function block that owns acknowledgement, passing that key. The block's instance path is derived by trimming the last segment off SymbolPathMAIN.ErrorHandler.aHmiAlarms derives MAIN.ErrorHandler — which is right when the alarm array is a member of that block and wrong for every other layout; set AcknowledgeInstancePath for those. The returned deaReturnType is resolved by name, not by number: SUCCESS returns true, NOT_FOUND returns false, and any other member raises PlcAlarmAcknowledgeException naming it. Nothing is written to the array entry — on hardware the array is a read-only projection that accepts an IsAcked write and discards it, which is exactly why acknowledgement goes through the block instead. Naming the alarm by key also means no slot is addressed, so there is no window in which the acknowledgement could land on whatever alarm has since taken that slot. A vendor whose PLC acknowledges differently registers its own IPlcAlarmDialect; the shipped one is simply the default. Registering one before AddTwinCatAdsAlarms also takes the shipped dialect's configuration rules out of startup validation, so a PLC that needs no instance path is no longer asked for one.

The PLC method needs {attribute 'TcRpcEnable'}. A method without it is not reachable over ADS at all, and the call fails as an unknown method however correct the instance path and the name are. When acknowledgement raises AdsErrorException on a target whose alarms otherwise arrive normally, check the attribute on the method declaration first — it is the likeliest cause and the cheapest to rule out.

Restarts, offline targets and ordering

Every restart re-raises everything outstanding. The outstanding set lives in memory and starts empty, and ADS delivers one notification on registration — so the first snapshot after a host restart reports every alarm the PLC is currently holding as newly Raised. There is no way to tell a two-day-old alarm from one raised while the host was down without persisting the set. Forwarding Raised straight to a pager therefore pages the whole outstanding set on every deployment: deduplicate downstream on Key plus PlcTimestamp, or suppress the first snapshot after start.

A PLC that is down at boot does not fail the host. Its first subscription attempt cannot succeed, so it is logged at Error and retried automatically the next time that target's connection reports Connected — every other target monitors normally in the meantime, and the offline one simply reports no alarms until it comes back. Once a target is registered the core library's durable subscriptions take over and carry it across later reconnects. Registration is attempted for all targets concurrently, so ten offline PLCs cost one connection timeout, not ten. Only unreachability is treated this way: a SymbolPath the PLC does not have is a configuration fault that no reconnect will fix, and still fails startup — provided that target answered at boot. If it did not, there is no startup left to fail: the bad path surfaces on the deferred retry instead, where it is logged at Error and re-attempted on every reconnect, forever. Watch the log for a target that never reports Monitoring alarms on ….

Transitions for one target arrive in the order they were computed, so a consumer folding the stream into its own state never sees a Raised land after the Ended that followed it. That ordering is bought by holding the target's lock across delivery, which means a handler that blocks delays that target's next snapshot — do the minimum on the notification thread and hand the work off. Ordering is per target, not across targets.

IPlcAlarmHandler/AlarmChanged and Transitions treat a throwing consumer differently, on purpose. Registered handlers and AlarmChanged handlers are isolated from one another: one that throws is logged and the rest still receive that transition. Transitions is an ordinary IObservable<AlarmTransition> and follows the standard Rx contract — throwing from OnNext is an observer bug, and a subscriber that throws can starve the subscribers after it for that transition. Either way the exception never escapes onto the notification thread, and the next transition is still delivered.

Fault tolerance

A shape mismatch is loud, but not fatal. If the PLC's ST_ErrorEntry stops matching what the package binds, it throws PlcAlarmShapeException naming the member and the symbol path rather than degrading to defaults — a silently wrong alarm list is worse than an absent one. That snapshot is dropped whole and logged at Error, the outstanding set keeps its last good reading, and the subscription survives to recover on the next well-formed notification. The report is once per outage, not once per notification — a mismatch is a property of the PLC's type and would otherwise repeat at cycle rate for as long as the fault lasts. Further failures are counted silently; when a notification binds again the monitor says so at Information and names how many failed in between, and a fault that recurs after a recovery is reported in full again.

A bad value is not a shape mismatch, and stays in its own slot. That distinction is the difference between dropping one field and going blind: the alarm array is fixed-size with permanent slots, so an entry carrying nonsense arrives again on every notification, and treating it as a broken type would drop every snapshot for as long as it sat there. Two values are therefore kept rather than thrown on — an ErrorType outside E_ErrorType, which binds with its raw number, and a PLCTimeStamp that is not a real date, which reads as default(DateTime) exactly as a zeroed TIMESTRUCT already does. Each is logged once at Warning (per distinct value, and per slot, respectively) instead of on every cycle, and every other entry in the array binds normally. A missing or retyped member is still a shape mismatch and still behaves as above.

Health checks

builder.Services
    .AddHealthChecks()
    .AddTwinCatAdsHealthCheck()        // can each target be reached at all?
    .AddTwinCatAdsAlarmHealthCheck();  // how bad are the alarms it can see?

AddTwinCatAdsAlarmHealthCheck() reports Degraded at Warning and Unhealthy at Error by default, from the worst severity outstanding. It reports only that — it says nothing about whether monitoring is live, so a target still waiting for its first connection has no alarms and looks healthy here, indistinguishable from one that is connected and genuinely quiet. Register the core's AddTwinCatAdsHealthCheck() alongside it for per-target connectivity; the two together are the full picture.

See examples/Dahlke.TwinCAT.Ads.Examples.ErrorHandler for a runnable demo that walks a whole alarm lifecycle in simulation.