Alarms
Outstanding PLC alarms, acknowledgement, and real-time alarm transitions. Requires feature
flag Alarms enabled — disabled by default, and answers 501 rather than 404 when off (see
Error Codes).
Alarm monitoring itself is configured separately, in the PlcAlarms section — see
Configuration below. A PLC target absent from PlcAlarms:Targets is not
monitored even with the feature flag on.
List Outstanding Alarms
GET /api/plcs/{plcId}/alarmsPolicy: ReadAccess
curl http://localhost:5000/api/plcs/Line1/alarms{
"data": {
"count": 1,
"partial": false,
"failedPeers": [],
"items": [
{
"code": "Line1_Err_60",
"sourceId": "ads",
"scope": "Line1",
"severity": 3,
"severityLabel": "ERROR",
"status": "active",
"description": "Conveyor jam",
"lastOccurred": "2026-07-30T14:22:01",
"capabilities": ["acknowledge"],
"x-ads": {
"equipmentId": "Line1",
"errorCode": 60,
"slotIndex": 3,
"needsAcknowledgement": true,
"isAcknowledged": false,
"plcTimestamp": "2026-07-30T14:22:01",
"plcClock": "Unspecified"
}
}
]
},
"error": null
}lastOccurred, x-ads.plcTimestamp and x-ads.plcClock are absent when the PLC gave no
readable time — a live alarm carrying a zeroed TIMESTRUCT, or one whose TIMESTRUCT does
not describe a real date at all, which is what a stopped or garbled PLC clock produces. The
three are present as a set or absent as a set: a clock describes an instant, so with no instant
there is nothing for plcClock to describe. The alarm is still real, still listed and still
acknowledgeable — only its time is unknown. reoccurred detection degrades for it, though: that
transition fires when the fault condition returns or the PLC timestamp advances while the
alarm is already active, and only the first of those can ever fire once the timestamp binds
unset on every notification — so a re-trigger that never drops IsActive produces no
transition, no stream frame, no payload change. Still strictly better than before 0.8.0, when
one such alarm froze the whole endpoint outright. adsify omits the keys rather than reporting
0001-01-01T00:00:00, because a fabricated date is indistinguishable from a real one and sorts
as the oldest alarm in the plant. Do not sort or age on lastOccurred without handling its
absence.
status is one of active, passive or cleared — see Outstanding vs. active
below. x-ads carries fields specific to the ADS source, namespaced so a second alarm supplier
can add its own extension alongside it without breaking this shape.
An unknown plcId, or one with no PlcAlarms:Targets entry, is 404 PLC_NOT_FOUND, not an
empty 200. A PLC target that exists in PlcTargets but has no PlcAlarms:Targets entry can
never be monitored — that is a permanent configuration fact, not a transient one — so adsify
reports it the same way it reports a plcId unknown to the pool entirely, rather than a 200
whose empty items a caller could mistake for “no alarms”. Returning an empty list for a PLC
nothing is watching would be indistinguishable from a quiet, healthy plant.
A read that partially fails is still 200. A configured scope whose connection state
(per GET /api/plcs) is not Connected — Disconnected or Connecting — cannot be read
right now, and this is what partial/failedPeers exist for: partial becomes true,
failedPeers names the scope as sourceId:scope, and x-ads-failed carries the reason.
items still carries whatever the last-good reading for that scope was, alongside every alarm
adsify could freshly read — a scope named in failedPeers may therefore also appear in
items, and its entry there should be treated as possibly stale. Failing the whole request, or
silently dropping the down scope’s last reading, would both throw away information that is
real. This is the mechanism a down or never-connected PLC actually triggers; a PLC absent from
PlcAlarms:Targets does not (see the 404 case above) — it is excluded from this feature
entirely rather than counted as one more failure, so a plant that has only wired up alarm
monitoring on some PLCs does not see partial: true on every fleet read as a result.
Acknowledge an Alarm
POST /api/plcs/{plcId}/alarms/{key}/acknowledgePolicy: WriteAccess
curl -X POST http://localhost:5000/api/plcs/Line1/alarms/Line1_Err_60/acknowledge{ "data": { "acknowledged": true }, "error": null }This is an RPC call to the function block that owns acknowledgement (configured via
AcknowledgeInstancePath / AcknowledgeMethod, below), not a write to the alarm array entry —
the array is a projection the PLC rebuilds from its own state every scan, so a direct write
would be overwritten within one cycle.
| Status | Code | Meaning |
|---|---|---|
200 | — | Acknowledged |
404 | ALARM_NOT_FOUND | Nothing outstanding under that key — already cleared, already acknowledged, or never existed |
404 | PLC_NOT_FOUND | Unknown or unmonitored plcId |
502 | ALARM_ACK_FAILED | The PLC refused the acknowledgement — distinct from “not found”: retry may succeed |
Stream Alarm Transitions (SSE)
GET /api/plcs/{plcId}/alarms/stream
GET /api/alarms/stream (fleet-wide — every PLC the caller may see)Policy: ReadAccess
curl -N http://localhost:5000/api/plcs/Line1/alarms/streamevent: alarm
data: {"kind":"raised","alarm":{ ... same shape as List Outstanding Alarms items ... }}kind is one of raised, acknowledged, cleared, reoccurred or ended. On ended,
kind is the authority, not the payload: the alarm carries its last reading before ending, so
x-ads.isAcknowledged may still read false even though the alarm is gone. Ordering is
guaranteed within one PLC’s stream only — never across PLCs on the fleet-wide route.
An unknown or unmonitored plcId on the per-PLC route is 404 PLC_NOT_FOUND, matching
GET .../alarms — never a silent 200 text/event-stream that connects and simply never
emits. The fleet-wide route has no plcId to validate this way; scope comes from the
caller’s claim (below), same as the fleet read.
The connection sends a heartbeat frame (event: heartbeat\ndata: {}\n\n) every 30 seconds
so a client — or an intermediary proxy timing out an idle connection — can tell a quiet PLC
from a dead one, the same purpose the Notifications SSE endpoint’s heartbeat serves.
This route is subject to the same connection accounting as every other stream in this API —
the ConnectionLimits settings (see
Configuration) all apply, and the
connection is visible to (and can be terminated from) GET /api/admin/connections.
Stream Alarm Transitions (WebSocket / SignalR)
WS /api/plcs/{plcId}/alarms/wsPolicy: ReadAccess
Browser WebSocket connections cannot send Authorization headers — pass the JWT via query
string: ?access_token=<jwt>. Streams the same transitions as the SSE route, scoped to the
plcId in the route only (no fleet-wide hub).
List Outstanding Alarms (Fleet-Wide)
GET /api/alarmsPolicy: ReadAccess
Same response shape as the per-PLC route, aggregated across every PLC the caller’s
plc_access claim permits. This route carries no plcId, so an unrestricted caller sees
every monitored PLC and a restricted one sees the intersection — the claim, not the route,
does the filtering here.
Rate Limiting
Rate limiting is not uniform across these six routes — worth knowing before tuning
RateLimiting or debugging a 429:
| Routes | Policy | Default | Partition | Kind |
|---|---|---|---|---|
GET .../alarms, GET /api/alarms | AlarmReads | 50/s | Per user, per PLC | Per-request |
POST .../acknowledge | RpcInvocations | 10/s | Per user, per PLC | Per-request |
GET .../alarms/stream, GET /api/alarms/stream (SSE) | NotificationConnections (shared with the Notifications SSE endpoint) | 5 per 60s | Per user only — one shared budget across every PLC | Connection-establishment — not throttled once a stream is open |
WS .../alarms/ws | — | — | — | No rate-limit policy applied — EtherCatHub’s WebSocket route is likewise unthrottled |
Acknowledge is metered as an RPC, not a read. It is a write-effect ADS call to the PLC’s
AcknowledgeAlarm method (see above) — the identical class of operation POST .../methods/{methodPath} performs — so it shares that budget (RpcInvocations, 10/s)
rather than AlarmReads’ far more permissive 50/s.
Outstanding vs. active
An alarm is outstanding while its fault is present OR it still awaits acknowledgement.
That second case is the ISA-18.2 “returned to normal, unacknowledged” state, reported as
status: "passive" — distinct from "active" (fault still present) and "cleared" (alarm has
ended). An alarm that clears before an operator sees it therefore stays visible, as passive,
until acknowledged; it does not silently vanish from the outstanding list.
Configuration
{
"PlcAlarms": {
"TextCatalog": "alarms.json",
"Targets": {
"Line1": {
"SymbolPath": "GVL_Errors.aErrors",
"CycleTimeMs": 200,
"PlcClock": "Unspecified",
"AcknowledgeInstancePath": "MAIN.fbErrorHandler",
"AcknowledgeMethod": "AcknowledgeAlarm"
}
}
}
}PlcAlarms is bound directly by the Dahlke.TwinCAT.Ads.Alarms package, not by adsify — a
target absent from Targets is simply not monitored, even with Features:Alarms enabled, and
adsify’s alarms feature excludes it from Scopes entirely rather than reporting it as a
failure: see the 404 case above. GET /api/plcs lists every PlcTargets entry regardless of
whether it also has a PlcAlarms:Targets entry — the two sections are independent, and a PLC
can appear there while still answering 404 PLC_NOT_FOUND from every alarms route.
| Field | Description | Default |
|---|---|---|
TextCatalog | Path to a JSON file mapping the PLC’s alarm key to human-readable text. Relative paths resolve against the host’s content root (not the process working directory), so "alarms.json" next to appsettings.json works the same under dotnet run and in a published deployment. | null (no text) |
SymbolPath | Fully-qualified symbol path of the PLC’s alarm array | (required) |
CycleTimeMs | How often the PLC pushes array changes | 200 (must be positive if overridden) |
PlcClock | What the PLC’s TIMESTRUCT is expressed in — Unspecified, Utc or Local | Unspecified |
AcknowledgeInstancePath | Instance path of the function block that owns acknowledgement | derived from SymbolPath’s parent segment |
AcknowledgeMethod | The PLC method that acknowledges one alarm by key | AcknowledgeAlarm |
PlcClock defaults to Unspecified deliberately — leave it there unless you know the PLC’s
clock. A TIMESTRUCT carries no time zone, so this cannot be inferred; it has to be told.
Declaring the wrong one is worse than declaring none: a consumer that calls
ToUniversalTime() on a mis-declared timestamp shifts every alarm in the plant by the
host’s UTC offset, and a shifted timestamp looks exactly like a correct one. There is no way
to detect this from the data alone — only from knowing your PLC’s actual clock configuration.ASPNETCORE_ENVIRONMENT=Simulation needs a seeded alarm array and an
AcknowledgeAlarm handler set up in code; InitialValues in appsettings.*.json cannot seed
either one.A broken type leaves GET /alarms silently stale — unlike a connection drop, which is
reported. A broken value no longer does. If the PLC’s ST_ErrorEntry stops matching the
shape the alarms package binds — a renamed or retyped member — the monitor logs an Error
naming the member and symbol path, drops that snapshot, and keeps serving its last good
reading rather than a partially-bound one. The connection itself stays Connected
throughout, so this does NOT trip partial/failedPeers the way a Disconnected/Connecting
scope does (see above) — while it’s failing, nothing in the response marks the data as stale:
no timestamp, no flag, nothing. The only signal is the log line — watch for does not match the shape this package binds.
A member that is present and correctly typed but carries nonsense is a different matter, and it
stays confined to the entry carrying it. An unrecognised ErrorType is preserved as UNKNOWN
with its raw value — unchanged since 0.7.0 — logged once per distinct value per target. Since
0.8.0, an out-of-range PLCTimeStamp gets the same confinement instead of throwing: it binds as
unset (the three time fields are omitted — see above), logged once per entry per target rather
than on every notification. Either way the rest of the array is unaffected, and the snapshot is
not dropped. Before 0.8.0 a single corrupt timestamp threw the same exception as a renamed
member, so one bad slot in a fixed-size array froze the whole endpoint at its last good reading
indefinitely and hid every alarm raised afterwards.
ConnectionState. A target that is
Connected but whose SymbolPath points at the wrong symbol — the most likely operator
error when wiring up a new target — has no store entry for it, so GetOutstanding returns
[], the connection state still reads Connected, and the API answers
{count: 0, partial: false, items: []} forever: identical to a genuinely quiet, healthy PLC.
Like the broken-type case above, there is currently no field in the response that
distinguishes the two — check that the configured SymbolPath actually resolves on the PLC
rather than trusting an empty, non-partial response as “no alarms.”Dropped stream events
AlarmBroadcaster never blocks alarm monitoring for a slow SSE/WebSocket client — each
subscriber gets a bounded queue, and once full, the oldest queued event is dropped so
monitoring for that PLC keeps moving. Every drop is counted in a process-lifetime counter
(AlarmBroadcaster.DroppedEventCount), but nothing in this release exposes that counter
through an endpoint, a log line, or the health check. If a client suspects it missed a
transition, there is currently no curl that will confirm it — only custom in-process
instrumentation reading that property directly.
Health check
AddTwinCatAdsAlarmHealthCheck() is registered alongside the existing plc-connectivity
check and reports from the worst outstanding alarm severity. It is served on GET /health/alarms, anonymously, and only when this feature is enabled:
curl -s http://localhost:5000/health/alarms{
"status": "Degraded",
"checks": [
{ "name": "twincat_ads_alarms", "status": "Degraded", "description": "worst outstanding severity: Warning" }
]
}Healthy and Degraded answer 200; a severity high enough to report Unhealthy answers
503.
/health/ready does not reflect alarm severity — it filters to plc-connectivity by name.
That separation is deliberate: a target still waiting for its first connection has no alarms
and therefore reports healthy, so folding this check into the readiness gate would make “not
connected yet” look ready.