Skip to content

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}/alarms

Policy: 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 ConnectedDisconnected 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}/acknowledge

Policy: 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.

StatusCodeMeaning
200Acknowledged
404ALARM_NOT_FOUNDNothing outstanding under that key — already cleared, already acknowledged, or never existed
404PLC_NOT_FOUNDUnknown or unmonitored plcId
502ALARM_ACK_FAILEDThe 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/stream
event: 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/ws

Policy: 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/alarms

Policy: 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:

RoutesPolicyDefaultPartitionKind
GET .../alarms, GET /api/alarmsAlarmReads50/sPer user, per PLCPer-request
POST .../acknowledgeRpcInvocations10/sPer user, per PLCPer-request
GET .../alarms/stream, GET /api/alarms/stream (SSE)NotificationConnections (shared with the Notifications SSE endpoint)5 per 60sPer user only — one shared budget across every PLCConnection-establishment — not throttled once a stream is open
WS .../alarms/wsNo 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.

FieldDescriptionDefault
TextCatalogPath 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)
SymbolPathFully-qualified symbol path of the PLC’s alarm array(required)
CycleTimeMsHow often the PLC pushes array changes200 (must be positive if overridden)
PlcClockWhat the PLC’s TIMESTRUCT is expressed in — Unspecified, Utc or LocalUnspecified
AcknowledgeInstancePathInstance path of the function block that owns acknowledgementderived from SymbolPath’s parent segment
AcknowledgeMethodThe PLC method that acknowledges one alarm by keyAcknowledgeAlarm
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.
Alarms are not enabled in the Simulation profile. A simulated ADS connection throws on any unseeded RPC call rather than returning something plausible — and acknowledgement is an RPC call — so a simulated acknowledgement would fail loudly rather than silently no-op. Enabling alarms under 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.

“Connected but alarm monitoring never registered” is the same silent-quiet-plant failure, one layer over. Health here is derived purely from 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.