Skip to content
EtherCAT Diagnostics

EtherCAT Diagnostics

Real-time EtherCAT fieldbus diagnostics — master state, slave health, error counters, and frame statistics. Exposes the same data as Beckhoff TE2000 HMI EtherCAT Diagnostics, but as a REST + SignalR API.

Feature flag: EtherCatDiagnostics (disabled by default)

Multi-Master Support

A PLC can have multiple EtherCAT masters. The API scopes all slave/sync-unit endpoints under a specific master.

GET /api/plcs/{plcId}/ethercat/masters                              List masters
GET /api/plcs/{plcId}/ethercat/masters/{masterId}                   Master detail
GET /api/plcs/{plcId}/ethercat/masters/{masterId}/slaves            Slave list
GET /api/plcs/{plcId}/ethercat/masters/{masterId}/slaves/{address}  Slave detail
POST /api/plcs/{plcId}/ethercat/masters/{masterId}/slaves/{address}/reset-error-counters
GET /api/plcs/{plcId}/ethercat/masters/{masterId}/topology          Physical topology
GET /api/plcs/{plcId}/ethercat/masters/{masterId}/syncunits         Sync units (not implemented)

List Masters

GET /api/plcs/{plcId}/ethercat/masters

Returns all EtherCAT masters discovered for this PLC. The master’s AMS NetId is derived from the PLC’s NetId (typically byte 5 differs, e.g., PLC=192.168.1.136.1.1 → master=192.168.1.136.3.1).

{
  "data": [
    {
      "deviceId": 0,
      "name": "EtherCAT Master 0",
      "amsNetId": "192.168.1.136.3.1",
      "currentState": "Op",
      "requestedState": "Op",
      "slaveCount": 3,
      "diagnosticsDegraded": false,
      "incompleteReads": [],
      "lastUpdated": "2026-07-30T14:22:01.482Z"
    }
  ]
}
FieldDescription
deviceIdInteger ID used in URL path ({masterId})
currentStateEtherCAT state: Init, PreOp, Bootstrap, SafeOp, Op. A master answering a code outside that set reports Unknown (raw state nibble 0) or Unknown(0xNN) carrying the raw byte (any other undefined nibble)
slaveCountNumber of configured slaves
diagnosticsDegradedtrue when the most recent poll cycle could not read this master at all — the response is the last reading that succeeded — see Degraded diagnostics
incompleteReadsNames of the reads that did not answer in the cycle this response’s reading came from[] when everything answered. On a fresh response that is the current cycle; on a diagnosticsDegraded: true response it is the stale cycle’s, at lastUpdated. See Partial: incompleteReads
lastUpdatedWhen the reading in this response was taken

Master Detail

GET /api/plcs/{plcId}/ethercat/masters/{masterId}

Includes frame statistics.

{
  "data": {
    "deviceId": 0,
    "name": "EtherCAT Master 0",
    "amsNetId": "192.168.1.136.3.1",
    "currentState": "Op",
    "requestedState": "Op",
    "slaveCount": 3,
    "frameStatistics": {
      "cyclicSendFrames": 2033684256,
      "queuedSendFrames": 312674,
      "cyclicLostFrames": 0,
      "queuedLostFrames": 52167,
      "cyclicFramesPerSecond": 999.6,
      "queuedFramesPerSecond": 0.4,
      "cyclicTxRxErrors": 0,
      "queuedTxRxErrors": 0
    },
    "diagnosticsDegraded": false,
    "incompleteReads": [],
    "lastUpdated": "2026-07-30T14:22:01.482Z"
  }
}

frameStatistics is null when this cycle’s frame-counter read (IG 0x0C) did not answer. That read is all-or-nothing: the block is one reading of five counters, so a master that answers only part of it is treated as not having answered at all, rather than serving the counters it did reach alongside zeros for the rest.

cyclicFramesPerSecond and queuedFramesPerSecond are independently null whenever the rate cannot be computed — the first cycle after startup, or any cycle whose previous counters were null — rather than the 0 that 0 now genuinely means. See Partial: incompleteReads.

cyclicTxRxErrors and queuedTxRxErrors are always 0 — adsify does not read them. See Fields adsify never reads.

Degraded diagnostics

Diagnostics are read from the EtherCAT master over ADS on every poll cycle. Those reads can fail — the master is unreachable, TwinCAT has left Run, or a read times out under real-time load. When a read fails, adsify reports that fact instead of substituting a value for it.

Two different things can go wrong, and the API keeps them apart.

diagnosticsDegraded: trueincompleteReads: [...]
What happenedadsify could not read this master at alladsify read the master, but not all of it
The response isthe last reading that succeeded, at lastUpdated — stale, not currentfresh
Missing fieldsexactly what that stored reading already had — it is re-served unchanged, keeping its own nulls and its own incompleteReadsnull, and the reads that failed are named
Eventssuppressed; adsify has nothing it observed to reportflow normally
SignalRMasterDiagnosticsDegradedEvent, once on entering and once on leavingnone — see below
/health/readyDegraded, master named in datareported in data, status unaffected

That last row is the anonymous route to both signals. Everything else on this page is [Authorize], so a monitoring sidecar without a token could not see an outage at all; the ethercat-diagnostics check on /health/ready reports it without one. incompleteReads deliberately does not change the status: a host whose frame-counter index group is unsupported reports frameStatistics incomplete forever, and treating that as a fault would make it permanently unready for a reason that is not one.

The two columns are not mutually exclusive. Degrading a master does not rewrite the stored snapshot — it only flags it — so a degraded response carries whatever the last good cycle produced, including a non-empty incompleteReads if that cycle was itself partial. The ordinary outage progression reaches both at once:

CycleWhat happenedStoredServed as
Nframe-counter read dropped, master and slave list fineyes, with frameStatistics: nullincompleteReads: ["frameStatistics"], diagnosticsDegraded: false
N+1master state read dropped toono — cycle N’s snapshot is keptthe same body, now diagnosticsDegraded: true

So on a degraded response, read incompleteReads as a fact about the cycle that produced the reading at lastUpdated, not about now. What is true of now on such a response is only this: adsify could not read this master at all this cycle.

What a failed read is never. adsify does not fill in a plausible-looking reading for a read that did not answer: a field fed by a read that did not answer is null, never 0, false or []. That guarantee is about reads. It says nothing about the six fields adsify never reads at all, which are a fixed 0 and are listed under Fields adsify never reads — check that list before treating any 0 as a measurement.

None of these mean “the read failed”:

ReadingMeans
currentState: "Unknown"The master answered a state code whose low nibble is 0 — outside ETG.1000’s set (1/2/3/4/8)
currentState: "Unknown(0xNN)"The master answered some other code outside that set; 0xNN is the raw state byte
slaveCount: 0, empty slave listThe master answered that it has no configured slaves
crcErrorSum: 0The slave’s counters were read and every port is clean

and none of these mean anything except “not read this cycle”:

ReadingMeans
frameStatistics: nullThe frame counter read (IG 0x0C) did not answer — in whole or in part; a block short of all five counters is treated as no answer
crcErrorSum: null, errorCounters: nullThe slave’s CRC counter read (IG 0x12) did not answer
a port’s crcErrorCount: null where configured: trueAlmost always a failed read of that port’s counter — see Port Reporting for the narrow exceptions and how to tell them apart. On a configured: false port the null is not about a read at all — it means “no link, so no counter”
identityMatch: null, configuredIdentity: null, ports: nullThe slave’s detail read (IG 0x11/0x09/0x12) did not answer
scannedIdentity: nullEither the scan (in whole, or just this slave’s own identity within it) did not answer, or this slave is not physically on the bus. incompleteReads makes the null explainable, not confirmed: a slave genuinely off the bus looks identical to one whose own identity read failed, in a cycle where it was actually some OTHER slave’s identity read that failed — see Partial: incompleteReads
cyclicFramesPerSecond: nullThe rate is a delta against the previous cycle’s counters, and there were none to delta against

identityMatch is true or null only — it is never false. This is a known limitation. The master’s standard ADS interface exposes no way to read a slave’s identity as physically detected separately from what the project configured: configuredIdentity and scannedIdentity are both built from the same per-address identity read (IG 0x11), so there is nothing independent for EtherCatClient to compare one against the other. It reports identityMatch: true whenever the detail read behind it answered, and null when it did not — never the result of an actual comparison. configuredIdentity and scannedIdentity are read from the same register (IG 0x11) by two INDEPENDENT calls today — GetSlaveDetailAsync’s own per-slave read behind configuredIdentity, and the bus-wide GetScannedSlavesAsync scan behind scannedIdentity — not one shared reading copied into two fields. Either can be present while the other is absent: a cycle can answer this slave’s detail read while its scanned-identity read (or the scan as a whole) did not, or the reverse. This mirrors Sync Units’ tone: a real gap in what the master’s ADS interface exposes (closing it would need ESC register access or EoE mailbox queries — documented in place in EtherCatClient), stated plainly rather than covered by a plausible-looking false. A slave physically swapped for a different device is not detected by identityMatch.

Fields adsify never reads

Six wire fields are constants, not readings. There is no ADS read behind them at all: the per-slave error-counter block (IG 0x12) is one uint32 CRC counter per linked port and carries nothing else, and the master’s frame-counter block (IG 0x0C) is five uint32s of which none is a Tx/Rx error count. adsify reads no other index group that would supply any of them.

FieldValueWhere it would have come from
errorCounters.abnormalStateChangesalways 0not in IG 0x12
errorCounters.connectionLossesalways 0the sum of every port’s lostLinkCount below, so it inherits the same gap
each port’s forwardedCrcErrorsalways 0not in IG 0x12
each port’s lostLinkCountalways 0not in IG 0x12
frameStatistics.cyclicTxRxErrorsalways 0not in IG 0x0C
frameStatistics.queuedTxRxErrorsalways 0not in IG 0x0C

A 0 in any of these is a placeholder, and nothing on any bus will move it. They never appear in incompleteReads — that list names reads that did not answer, and these have no read to fail. They go null only when the object around them does: errorCounters or frameStatistics being null as a whole, or a port having no counter entry (see Port Reporting).

Concretely: an EK1122 branch cable that drops link every few seconds still reports connectionLosses: 0 and lostLinkCount: 0 on every port on every cycle, with incompleteReads: [] and diagnosticsDegraded: false. Do not alarm on these six. Watch crcErrorSum / crcErrorCount, linkState and the slave’s currentState / isPresent instead — those are real readings, and a dropping link moves them.

This is stated for the same reason Sync Units is: it is a real gap in what the master’s ADS interface exposes, and a plausible-looking zero would hide it. The fields are kept on the wire rather than removed, so the response shape stays stable for existing consumers; naming them here is what stops that shape from lying.

Blind: diagnosticsDegraded

Two reads are diagnostics-critical: the master state read, and the configured slave list read (covering the slave count, fixed addresses and per-slave states). Every state and presence event is derived from them, so a failure in either means adsify has no reading at all for that cycle. So does a cycle that runs past its budget — see Poll cycle budget.

The reason on the event names which:

reason
master state unavailableIG 0x03 or IG 0x06 did not answer
configured slave list unavailableIG 0x07 or IG 0x09 did not answer, or answered short
poll cycle budget exceededThe cycle ran past PollCycleBudgetMs and was abandoned — or PollCycleBudgetMs is configured to a value that cannot bound anything (zero or negative), in which case no cycle is attempted at all. The two are not distinguished on the wire; the Warning in the log says which, and names the offending value for the misconfiguration case

A master polled every second while unreachable produces two events in total, not one per second: one on entering the degraded state and one on leaving it.

On recovery, the new reading is compared against the last known-good one, not against the degraded cycle. A state change that happened while reads were down is therefore still reported: a master that went Op → (reads fail) → SafeOp emits the recovery event followed by a MasterStateChangedEvent carrying previousState: "Op", currentState: "SafeOp".

Partial: incompleteReads

Everything else adsify reads — frame counters, scanned identities, per-slave detail and per-slave error counters — decorates the snapshot without feeding change detection. One of them failing does not degrade the master: its state and every slave’s state were genuinely read, so the snapshot is fresh and its events are real. What is missing is named instead:

{
  "deviceId": 0,
  "currentState": "Op",
  "frameStatistics": null,
  "diagnosticsDegraded": false,
  "incompleteReads": ["frameStatistics", "slaveErrorCounters"],
  "lastUpdated": "2026-08-02T09:14:07.221Z"
}
NameThe read behind it
frameStatisticsMaster frame counters, IG 0x0C
scannedIdentitiesThe scanned identity list failed entirely (IG 0x06/0x07), or at least one slave’s own identity within it did (IG 0x11)
slaveDetailAt least one slave’s identity, init-error state and ports — IG 0x11/0x09/0x12
slaveErrorCountersAt least one slave’s CRC counters, IG 0x12

incompleteReads is scoped to the whole snapshot, and that means it can over-report for any one slave. One slave’s failed detail read sets the master-level flag for the entire cycle — so GET .../slaves/1001 can legitimately answer incompleteReads: ["slaveDetail"] with every field on that response populated, because it was slave 1002’s read that failed, not 1001’s. Read a name in incompleteReads as “may explain a null here,” never as “does”: find which slave actually failed by looking for the null fields on that slave’s own response, not by trusting the flag name alone. GET .../slaves/{address} carries its own incompleteReads, scoped to the reads that can explain a null on that response — slaveDetail, slaveErrorCounters and scannedIdentities, never frameStatistics, which is master-level and has no bearing on a single slave.

There is no SignalR event for this. A partial read is a property of the data you fetched, not an occurrence — and a read that flaps on a 1 s poll would otherwise fire two events per second at every subscriber. Poll the REST endpoint.

Poll cycle budget

PollCycleBudgetMs (default 5000) bounds one master’s poll cycle. A cycle that exceeds it is abandoned: the last known-good snapshot is kept and the master is marked degraded with reason: "poll cycle budget exceeded".

The bound is soft, by up to one read. The deadline is checked between reads, not after the very last one, so an overrun landing on the final slave’s read still lets that read finish, and a complete cycle is stored rather than discarded — the reading is complete, so nothing is fabricated, and discarding a complete reading for finishing 50 ms late would be worse than keeping it. Expect the cycle to be bounded to within roughly one read of the limit, not to the millisecond.

"EtherCat": {
  "PollingIntervalMs": 1000,
  "CrcErrorThreshold": 100,
  "EnableNotifications": true,
  "PollCycleBudgetMs": 5000
}

A non-positive PollCycleBudgetMs is a misconfiguration, and it degrades rather than throws. A budget of 0 or less cannot bound anything, so no cycle is attempted: the master is reported degraded with reason: "poll cycle budget exceeded" — the same reason a genuine overrun gives, so a subscriber cannot tell a typo from a slow rack — and the snapshot stays frozen at its last known-good reading until the value is corrected. The Warning logged on entering the degraded state is where the two are distinguished: it names the configured value and says it cannot bound a poll cycle. Both the Warning and the SignalR event fire once on transition, not once per poll.

Without it, a partial ADS outage stretched one cycle to minutes — each failed read burns its full timeout, and the reads run sequentially, so an 8-slave rack costs roughly 25 of them. The snapshot froze for that entire window while diagnosticsDegraded stayed false.

An abandoned cycle never stores what it managed to read. A slave list truncated halfway reaches change detection as “the rest of the rack disappeared”, which would fabricate presence events for slaves that never went anywhere.

A read genuinely stops early when the budget cancels it, rather than running to its own timeout regardless: measured against real hardware, a read cancelled mid-flight returned in 202 ms, against 20,013 ms for the same read left uncancelled — so the deadline cuts a hung read short instead of waiting it out.

How stale a reading can actually get. The budget bounds one master’s cycle — one master, once. It is not the whole bound, because the polling loop is sequential at two levels above it and neither is covered:

  • Master discovery runs once per PLC, before that PLC’s masters are polled, and the budget does not cover it. It has its own bound: a 2 s timeout per candidate probe. In steady state that is one probe per cached master. When a cached master stops answering, its failed probe is paid first and then triggers a full sweep of up to five candidates, so the ceiling is (M + 5) × 2 s. A cache that simply aged out (1-minute lifetime) pays the sweep alone, 5 × 2 s.
  • The PLC loop and the master loop are both sequential. The service walks every configured PLC in turn, and within each PLC every master in turn, and only then waits PollingIntervalMs before starting again. Nothing runs in parallel.

So for P configured PLCs with M masters each, the interval between two readings of any one master is:

P × (master discovery + M × PollCycleBudgetMs) + PollingIntervalMs

At defaults (PollCycleBudgetMs: 5000, PollingIntervalMs: 1000), taking discovery as M × 2 s typical and (M + 5) × 2 s worst:

ShapeTypicalWorst
1 PLC, 1 master1 × (2 + 5) + 1 = ~8 s1 × (12 + 5) + 1 = ~18 s
1 PLC, 4 masters1 × (8 + 20) + 1 = ~29 s1 × (18 + 20) + 1 = ~39 s
4 PLCs, 2 masters each4 × (4 + 10) + 1 = ~57 s4 × (14 + 10) + 1 = ~97 s

Every extra PLC adds a whole discovery and all of its masters’ budgets; every extra master on a PLC adds another budget and another probe. Size PollCycleBudgetMs against P × M, not against one master.

Earlier revisions of this page gave a flat “~7 s typical, ~15 s worst” and described the budget as “per master” without either loop. That figure was the one-PLC, one-master case with the poll interval left out and discovery’s invalidation path understated; it is not a ceiling for any other shape.

A rack wide enough to need longer than the budget should raise it; the overrun logs a Warning naming the budget so it is clear which knob to turn.

List Slaves

GET /api/plcs/{plcId}/ethercat/masters/{masterId}/slaves

Returns all configured slaves with their state and device type. Device names are decoded from the Beckhoff product code (e.g., EK1100, EL2808).

{
  "data": [
    {
      "physicalAddress": 1001,
      "autoIncrementAddress": 0,
      "name": "EK1100",
      "type": "EK1100",
      "currentState": "Op",
      "requestedState": "Op",
      "isPresent": true,
      "hasError": false,
      "isDisabled": false,
      "identityMatch": true,
      "crcErrorSum": 0
    }
  ]
}
FieldDescription
physicalAddressEtherCAT fixed address (used in URL as {address})
nameDecoded device type (e.g., EK1100), falling back to Slave {addr} — see the ambiguity note below
typeHardware type derived from the product code (e.g., EK1100, EL2808), or Unknown — see the ambiguity note below
currentStateEtherCAT state of this slave
isPresentPhysically present on the bus
hasErrorSlave is signaling an error
identityMatchtrue when this cycle’s detail read answered; null when it did not. Never false — see Degraded diagnostics for why it can’t detect a wrong device
crcErrorSumTotal CRC errors summed over the slave’s linked ports — the only ones the master reports a counter for. null when this cycle’s CRC-counter read did not complete, never 0 for that

name and type are ambiguous, and that ambiguity is not flagged. Both come from the same per-slave identity read (IG 0x11) taken while building the slave list, and that read is deliberately non-essential there — losing a device name must not blank out a whole rack’s diagnostics, so the slave stays listed at its address with its state intact either way. The consequence is that type: "Unknown" (with name: "Slave {addr}") has three possible meanings the response does not separate:

  • the identity read did not answer this cycle, or answered fewer than 8 bytes;
  • it answered a vendor ID of 0; or
  • it answered Beckhoff vendor 2 with a product code whose terminal number is 0.

A device from another vendor that does answer is not Unknown — it renders as Vendor(0xNNNN), carrying the vendor ID it reported, and name follows type.

Unlike the null fields elsewhere on this page, this pair is not named in incompleteReads: the slave-list read is a gating read, and the identity lookup inside it has no flag of its own. So type: "Unknown" is the one place left where “not read” and “read, but nothing to decode” still look alike. If the difference matters, GET .../slaves/{address} is served entirely from the cached snapshot — it triggers no read of its own — but that snapshot’s configuredIdentity comes from a separate per-slave identity read (IG 0x11, taken by GetSlaveDetailAsync during the poll cycle, independently of the slave list’s own identity lookup) and reports null when that read failed. Comparing the two fields does separate the cases.

This list endpoint carries no incompleteReads of its own. To find out why a field here is null, check the master detail’s incompleteReads, or GET .../slaves/{address} for this slave — but note that the per-slave one is not scoped to that slave either. It is the same whole-cycle list with frameStatistics masked out, so a name in it may have been set by a different slave’s failed read; see Slave Detail and Partial: incompleteReads.

Slave Detail

GET /api/plcs/{plcId}/ethercat/masters/{masterId}/slaves/{address}

Full slave detail including device identity, error counters, and port diagnostics.

{
  "data": {
    "physicalAddress": 1001,
    "name": "EK1100",
    "type": "EK1100",
    "configuredIdentity": {
      "vendorId": 2,
      "productCode": 72100946,
      "revisionNumber": 1179648,
      "serialNumber": 0
    },
    "scannedIdentity": { ... },
    "currentState": "Op",
    "isPresent": true,
    "hasError": false,
    "initError": false,
    "errorCounters": {
      "crcErrorSum": 0,
      "abnormalStateChanges": 0,
      "connectionLosses": 0
    },
    "ports": [
      { "port": "A", "physic": "EBus", "configured": true,  "linkState": true,  "crcErrorCount": 0,    "forwardedCrcErrors": 0,    "lostLinkCount": 0,    "unexpectedLink": false, "missingLink": false },
      { "port": "B", "physic": "EBus", "configured": true,  "linkState": true,  "crcErrorCount": 0,    "forwardedCrcErrors": 0,    "lostLinkCount": 0,    "unexpectedLink": false, "missingLink": false },
      { "port": "C", "physic": "none", "configured": false, "linkState": false, "crcErrorCount": null, "forwardedCrcErrors": null, "lostLinkCount": null, "unexpectedLink": false, "missingLink": false },
      { "port": "D", "physic": "none", "configured": false, "linkState": false, "crcErrorCount": null, "forwardedCrcErrors": null, "lostLinkCount": null, "unexpectedLink": false, "missingLink": false }
    ],
    "incompleteReads": [],
    "esi": {
      "vendorName": "Beckhoff Automation GmbH & Co. KG",
      "nameEn": "EK1100 EtherCAT Coupler",
      "nameDe": "EK1100 EtherCAT-Koppler",
      "group": "EtherCAT Coupler",
      "url": "http://www.beckhoff.de/EK1100"
    },
    "esiStatus": "resolved"
  }
}

configuredIdentity, scannedIdentity, initError, identityMatch, errorCounters and ports are null when the read behind them did not complete this cycle, rather than a zeroed or empty placeholder. Each port’s crcErrorCount, forwardedCrcErrors and lostLinkCount are null in that case too — and also, on a perfectly healthy slave, for any port that has no link; see Port Reporting for how to tell those two apart.

Three of the fields in the example above are constants rather than readings: errorCounters.abnormalStateChanges, errorCounters.connectionLosses, and every port’s forwardedCrcErrors and lostLinkCount are always 0 when present — see Fields adsify never reads.

identityMatch is otherwise always true, never false — see Degraded diagnostics for why it can’t detect a wrong device. incompleteReads on this endpoint is MASKED to the names that could ever explain a null here (slaveDetail, slaveErrorCounters, scannedIdentities) and never contains frameStatistics — but it is not otherwise scoped to this slave: the underlying flags are the master’s whole poll cycle, set when ANY configured slave’s read of that kind failed, this one or another — see Partial: incompleteReads for what a name in it does and does not tell you about this particular response.

Port Reporting

All four ESC ports (A–D) are always listed, linked or not. But the master returns one CRC counter per linked port, so the counter block’s length is the link count: a slave in the middle of a chain reports two linked ports (upstream and downstream), and the last slave reports one. configured and linkState follow that — they are not derived from the counter value, since a healthy port has zero CRC errors. Unlinked ports are reported with physic: "none" and configured: false.

Those two facts together mean the port list is longer than the counter list on almost every slave, so a null counter has two quite different meanings, and configured is what tells them apart:

On a port withcrcErrorCount: null means
configured: false (physic: "none")there is no link on this port, so there is no counter. Not a failed read. Never flagged in incompleteReads. This is the normal, healthy shape of ports C and D on a two-port terminal, on every cycle
configured: truethere is no counter for a port that has a link, which means the counter read did not cover it. Almost always the read failed outright, and slaveErrorCounters then appears in incompleteReads; crcErrorCount is never 0 for that

So a healthy two-port terminal on a healthy rack returns ports C and D with crcErrorCount: null while errorCounters is fully populated and incompleteReads is []. That is correct and expected — do not treat it as a failed read. Check configured first.

One narrow case sits between the two rows: the port list and the counter list come from two separate IG 0x12 reads within the same cycle — GetSlaveDetailAsync builds the port list from its own read, GetSlaveErrorCountersAsync builds the counter list from a second, independent read at the same address — so a link that comes up or goes down between them can leave a configured: true port without a counter for one cycle, with incompleteReads empty. That case resolves on the next cycle.

A configured: true port with a null counter that persists across cycles is usually, but not provably, a failed read — and when it is, slaveErrorCounters will usually say so. There is a second, non-transient route to the same shape: a persistently short answer to GetSlaveErrorCountersAsync’s own IG 0x12 read. CountReportedPorts derives the linked-port count from the response’s byte length alone, so it cannot tell “the master reported one linked port” apart from “the master reported two and the transport delivered one” — a block that truncates the same way every cycle silently drops the missing port’s counter, with incompleteReads staying empty, because the read still answered something and only an empty answer counts as failed there. This is the same short-read hazard this branch closed for the master’s frame counters (IG 0x0C, see Master Detail): that block is always exactly five counters, so anything short of that fixed length is now rejected outright as a failed read. IG 0x12 has no such fixed length to check against — it is genuinely one uint32 per linked port — so a truncated two-port answer and a correctly-reported one-port answer look identical on the wire. Treat a persisting null on a configured: true port as a failed read first, since that is still by far the more common cause — but if slaveErrorCounters keeps answering while one port’s counter stays stubbornly null, a consistently short IG 0x12 response is the other explanation worth checking.

forwardedCrcErrors and lostLinkCount follow the same null rule, but their non-null value is always 0 regardless: adsify does not read either — see Fields adsify never reads.

Limitation: the master’s ADS interface does not expose the port medium, so every linked port is reported as "EBus". A bus with cable-connected branches (EK1122 junction, EP box) is therefore rendered as one continuous trace rather than separate traces joined by cable links.

Device Identity from ESI

When an ESI directory is configured, slave detail carries an esi block holding the device’s own description, read from the vendor’s EtherCAT Slave Information XML.

Everything in this block is read from the ESI file. Nothing in it is synthesized. A field the file does not state is null — never an empty string, never a guess. This is the distinction against Beckhoff Product Code Decoding below: type is a value adsify infers from the product code’s bit layout, while esi.nameEn is what the vendor actually calls the device.

FieldMeaning
vendorNameVendor name from the ESI file’s <Vendor> element
nameEnEnglish device name (LcId 1033), falling back to an untagged <Name> when no 1033 entry exists — an unlabelled name in a vendor file is conventionally English
nameDeGerman device name (LcId 1031). No untagged fallback: claiming an unlabelled name as German would be a fabrication, so a file with only an untagged name yields nameDe: null
groupDevice group name, resolved via the device’s <GroupType>
urlVendor product page

Resolution is keyed on the slave’s scanned identity — vendor ID plus product code, preferring an exact revision match and otherwise the highest revision in the file that resolves it. The catalog stops at the first candidate file containing the vendor/product code and does not compare revisions across files, so an identity appearing in two ranked files resolves from whichever one is reached first, not necessarily the one holding the higher revision. It is deliberately not keyed on the configured identity: that is the device the project expects, and describing it as though it were the device present would be exactly the kind of conflation Degraded diagnostics exists to prevent. So a slave whose scan did not answer reports esi: null even though its configured identity would have resolved.

esi is null for several quite different reasons, and esiStatus is what tells them apart:

esiStatusesiMeaning
resolvedpopulatedThe device was found in the ESI set
notConfigurednullNo ESI directory is configured, or it does not exist
identityUnavailablenullThe scanned identity is absent — either the slave was not in the scan at all (not flagged in incompleteReads — see Partial: incompleteReads), or it was scanned but its own identity read did not answer (incompleteReads may name scannedIdentities)
notFoundnullThe identity is absent from the ESI set, or the search hit its budget
readFailednullAn ESI file could not be read or parsed

A device is parsed at most once per process and its outcome cached, so repeated requests never re-read the file.

Three limitations worth knowing:

  1. Cached failures persist until restart. A device that resolved notFound or readFailed keeps that status for the process lifetime. Correcting or adding an ESI file therefore needs an app restart to take effect.

  2. The lookup budget is checked between files, not mid-file. A single very large family file can overrun LookupBudgetMs on its own.

  3. Candidate ranking can miss badly, and not only for non-Beckhoff vendors. Files are searched in an order derived from the inferred type string. For a non-Beckhoff vendor that string is Unknown or Vendor(0x…), which prefixes no real filename, so the lookup falls back to scanning the directory alphabetically.

    But the heuristic also misses Beckhoff devices whose model number sits behind an x wildcard in the middle of the filename. Measured against the reference Beckhoff set (142 files, 868 MB) on an 8-slave rack: seven of eight terminals resolved in 16–732 ms, but EL1904 took 3.3 s — 67% of the default 5 s budget. It lives in Beckhoff ELx9xx.xml, not the Beckhoff EL19xx.xml the ranking bets on first; because x sorts after digits, ELx9xx falls to candidate #39 of 142, so resolving it streamed 605 MB.

    So the practical headroom on the default budget is roughly 1.5×, not the wide margin the number suggests. A larger ESI set, a slower disk, or a cold filesystem cache can push a device that is present to notFound. The Warning logged on budget exhaustion names LookupBudgetMs so it can be raised; a rack with safety or specialist terminals is the case most likely to need it.

Beckhoff Product Code Decoding

For Beckhoff devices (vendor ID 2), the product code encodes the device type:

  • Upper 16 bits = terminal number (e.g., 0x044C = 1100)
  • Lower 16 bits = device family (0x2C52 = EK coupler, 0x3052 = EL terminal, 0x4052 = EP box)

Topology

GET /api/plcs/{plcId}/ethercat/masters/{masterId}/topology

Reconstructs the physical bus layout from per-slave port data — which slaves chain together over EBus, which are joined by a cable link, and which head a hot-connect group.

{
  "data": {
    "type": "master",
    "traces": [
      {
        "traceIndex": 0,
        "nodes": [
          {
            "slaveAddress": 1001,
            "name": "EK1100",
            "type": "EK1100",
            "currentState": "Op",
            "isPresent": true,
            "hasError": false,
            "ports": [
              { "port": "A", "physic": "EBus", "configured": true, "linkState": true, "status": "ok" },
              { "port": "B", "physic": "EBus", "configured": true, "linkState": true, "status": "ok" }
            ]
          }
        ]
      }
    ],
    "cableLinks": [],
    "hotConnectGroups": [],
    "incompleteReads": []
  }
}

A slave whose detail read did not complete this cycle contributes an empty ports list rather than a guessed one, since there is no port data to chain it to a neighbour — it renders as the start of its own trace instead. incompleteReads here carries the whole cycle’s flags unmasked, including frameStatistics, which has nothing to do with topology — treat a non-empty list as “check whether any slave’s ports came back empty”, not as “the topology itself is degraded”.

Sync Units

GET /api/plcs/{plcId}/ethercat/masters/{masterId}/syncunits

Not implemented — returns 501 with NOT_IMPLEMENTED.

The endpoint exists so the gap is explicit. Returning an empty list would be indistinguishable from a master that genuinely has no sync units configured, which would tell you “there are none” when the truth is that adsify never reads them.

The PLC and master are still validated first, so a wrong path returns 404 as usual; only a request that would otherwise have succeeded gets 501.

A sweep of index groups 0x01-0x60 on the master port against real hardware turned up several responders that adsify does not otherwise use (0x22, 0x45, 0x48, 0x51, 0x53, 0x5B, 0x5D), but none could be attributed to sync units, and every one reads as zero or uniform on a healthy bus — so no interpretation could be validated. The fields this endpoint would expose (hasError, faultCounter, framesMissed) are exactly the ones that only become non-zero under a fault.

Consequently the SyncUnitFaultEvent SignalR event is never emitted.

Reset Error Counters

POST /api/plcs/{plcId}/ethercat/masters/{masterId}/slaves/{address}/reset-error-counters

Clears the CRC error counters of the addressed slave only. The master’s frame statistics are not affected. Requires WriteAccess policy (operator/admin + ads:write scope).

StatusMeaning
204 No ContentCounters cleared
404 Not FoundUnknown PLC, master, or slave
502 Bad GatewayThe master rejected the write — counters were not cleared

The CRC threshold notification for this slave is re-armed only on success, so a rejected reset will not replay CrcErrorThresholdExceededEvent for counters that are still above the threshold.

CoE — Object Dictionary Access

Feature flag: EtherCatCoe (disabled by default, and additionally requires EtherCatDiagnostics)

GET /api/plcs/{plcId}/ethercat/masters/{masterId}/slaves/{address}/coe/{index}/{subIndex}

Reads a single CoE (CANopen over EtherCAT) object via SDO upload. Index and subindex accept hex (0x1018) or decimal (4120). Requires ReadAccess.

GET /api/plcs/plc1/ethercat/masters/0/slaves/1004/coe/0x1008/0
{
  "data": {
    "index": 4120,
    "indexHex": "0x1008",
    "subIndex": 0,
    "byteLength": 6,
    "valueHex": "454c37303437",
    "interpretations": { "uint8": null, "uint16": null, "uint32": null, "string": "EL7047" }
  }
}

The object’s declared type lives in the device’s ESI file, which the master’s ADS interface does not expose. valueHex is therefore always the authoritative value, and interpretations offers best-effort readings: a width is populated only when the object’s length matches, and string only when the bytes are printable ASCII (trailing space/NUL padding trimmed).

Useful objects: 0x1008 device name, 0x1009 hardware version, 0x100A software version, 0x1018 identity (:01 vendor, :02 product code, :03 revision, :04 serial), 0x1C12/0x1C13 PDO assignment.

Not every slave has CoE

Only slaves with a mailbox can serve an object dictionary. On a typical bus the coupler and the plain digital I/O terminals cannot; complex terminals (drives, TwinSAFE) can.

StatusCodeMeaning
200Object read
400VALUE_OUT_OF_RANGEIndex or subindex is not parseable / out of range
404COE_NOT_SUPPORTEDSlave has no mailbox — it cannot serve CoE at all
404COE_OBJECT_NOT_FOUNDSlave supports CoE but lacks that object or subindex
404SLAVE_NOT_FOUND / MASTER_NOT_FOUND / PLC_NOT_FOUNDUnknown target
502ADS_ERRORTransport or device fault

Reads are on-demand and never polled. A slave without a mailbox does not answer at all, and the master exposes no index group listing which slaves have one, so the request can only run to timeout. A slave that does answer replies in tens of milliseconds. Folding CoE into the polling loop would cost a full timeout per mailbox-less terminal on every cycle.

Configuration

{
  "PlcTargets": {
    "Line1": {
      "EtherCat": {
        "CoeTimeoutMs": 3000,
        "CoeMaxObjectBytes": 1024
      }
    }
  }
}
FieldDescriptionDefault
CoeTimeoutMsADS timeout for one SDO read — the latency paid when a slave cannot answer3000
CoeMaxObjectBytesLargest object body accepted from a slave1024

Writing objects (SDO download) is not supported.

Real-Time Notifications (SignalR)

Hub: /api/plcs/{plcId}/ethercat/ws

Requires ReadAccess policy. JWT via ?access_token= query string.

On connect, the client is automatically subscribed to all EtherCAT events for that PLC. Events are broadcast to all connected clients.

The method name a client registers a handler for is the event’s type name, including the Event suffixconnection.on("MasterStateChangedEvent", ...). Earlier revisions of this page listed these names without the suffix; a handler registered under those names never fired.

EventPayloadDescription
MasterStateChangedEvent{ masterId, masterName, currentState, previousState, requestedState }Master state transition
SlaveStateChangedEvent{ masterId, address, name, currentState, previousState, hasError }Slave state change
SlavePresenceChangedEvent{ masterId, address, name, isPresent }Slave disappeared from, or changed presence within, the configured list
CrcErrorThresholdExceededEvent{ masterId, address, name, port, crcCount, threshold }CRC errors crossed threshold
MasterDiagnosticsDegradedEvent{ masterId, masterName, degraded, reason }adsify’s reads of this master stopped answering (degraded: true) or started answering again (degraded: false, reason: null) — see Degraded diagnostics
SyncUnitFaultEvent{ masterId, syncUnitId, hasError, faultCounter }Sync unit WC fault — never emitted, see Sync Units

MasterDiagnosticsDegradedEvent is a statement about the read, not about the bus: degraded: true says adsify could not read the master, not that the master reported a fault. It is emitted on transition only, and while degraded no other event is emitted for that master.

A slave that appears in the configured list for the first time emits nothing — SlavePresenceChangedEvent is emitted when a previously-known slave leaves the list or changes its isPresent flag, not when one is added.

Configuration

{
  "PlcTargets": {
    "Line1": {
      "AmsNetId": "192.168.1.136.1.1",
      "Port": 851,
      "EtherCat": {
        "PollingIntervalMs": 1000,
        "CrcErrorThreshold": 100,
        "EnableNotifications": true,
        "PollCycleBudgetMs": 5000
      }
    }
  }
}
FieldDescriptionDefault
PollingIntervalMsHow often to poll diagnostics data1000
CrcErrorThresholdCRC count that triggers notification100
EnableNotificationsEnable SignalR events for this PLCtrue
PollCycleBudgetMsWall-clock bound on one master’s poll cycle — see Poll cycle budget5000

ESI Configuration

The ESI catalog is configured app-level, not per PLC — an ESI set is a machine-wide vendor library, identical no matter which PLC is being interrogated:

{
  "EtherCat": {
    "Esi": {
      "Directory": "C:/TwinCAT/3.1/Config/Io/EtherCAT",
      "LookupBudgetMs": 5000
    }
  }
}
OptionDefaultMeaning
DirectoryunsetDirectory of vendor ESI XML files. Unset, blank or non-existent disables ESI enrichment entirely
LookupBudgetMs5000Wall-clock bound on one device’s first lookup, checked between candidate files

With Directory unset — the default — ESI enrichment is entirely inert: no additional reads happen, nothing is logged per slave, and every pre-existing field on every EtherCAT response is byte-identical to before this feature shipped. Two fields are new on Slave Detail regardless of configuration, though: esi is always present and always null, and esiStatus is always present and reads notConfigured. A single Information line at startup notes that enrichment is off; a configured-but-missing directory logs one Warning at startup instead.

Point Directory at a directory of .xml ESI files, such as a TwinCAT installation’s Config/Io/EtherCAT. Files are read on demand and never written to.

ADS Implementation Notes

EtherCAT diagnostics are read via ADS from the EtherCAT master device:

  • Master NetId: derived from PLC NetId (byte 5 varies, e.g., .3.1)
  • Port 0xFFFF: all diagnostic reads target the master’s AMS NetId on port 65535
  • Per-slave reads: use the slave’s fixed address as the IO offset (not ADS port)
  • Connection: routes through the PLC’s TCP connection — no separate ADS route needed

Key ADS index groups on master port 0xFFFF:

IGIODescription
0x030x100Master state (uint16)
0x060x0Slave count (uint16)
0x070x0Slave address list (uint16[])
0x090x0All slave states (byte[] — 2 per slave)
0x09slave addrPer-slave state + link
0x0C0x0Frame counters (5× uint32)
0x11slave addrSlave identity (4× uint32: vendor, product, revision, serial)
0x12slave addrCRC errors per port (4× uint32)