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/mastersReturns 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"
}
]
}| Field | Description |
|---|---|
deviceId | Integer ID used in URL path ({masterId}) |
currentState | EtherCAT 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) |
slaveCount | Number of configured slaves |
diagnosticsDegraded | true when the most recent poll cycle could not read this master at all — the response is the last reading that succeeded — see Degraded diagnostics |
incompleteReads | Names 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 |
lastUpdated | When 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: true | incompleteReads: [...] | |
|---|---|---|
| What happened | adsify could not read this master at all | adsify read the master, but not all of it |
| The response is | the last reading that succeeded, at lastUpdated — stale, not current | fresh |
| Missing fields | exactly what that stored reading already had — it is re-served unchanged, keeping its own nulls and its own incompleteReads | null, and the reads that failed are named |
| Events | suppressed; adsify has nothing it observed to report | flow normally |
| SignalR | MasterDiagnosticsDegradedEvent, once on entering and once on leaving | none — see below |
/health/ready | Degraded, master named in data | reported 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:
| Cycle | What happened | Stored | Served as |
|---|---|---|---|
| N | frame-counter read dropped, master and slave list fine | yes, with frameStatistics: null | incompleteReads: ["frameStatistics"], diagnosticsDegraded: false |
| N+1 | master state read dropped too | no — cycle N’s snapshot is kept | the 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”:
| Reading | Means |
|---|---|
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 list | The master answered that it has no configured slaves |
crcErrorSum: 0 | The slave’s counters were read and every port is clean |
and none of these mean anything except “not read this cycle”:
| Reading | Means |
|---|---|
frameStatistics: null | The 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: null | The slave’s CRC counter read (IG 0x12) did not answer |
a port’s crcErrorCount: null where configured: true | Almost 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: null | The slave’s detail read (IG 0x11/0x09/0x12) did not answer |
scannedIdentity: null | Either 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: null | The 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.
| Field | Value | Where it would have come from |
|---|---|---|
errorCounters.abnormalStateChanges | always 0 | not in IG 0x12 |
errorCounters.connectionLosses | always 0 | the sum of every port’s lostLinkCount below, so it inherits the same gap |
each port’s forwardedCrcErrors | always 0 | not in IG 0x12 |
each port’s lostLinkCount | always 0 | not in IG 0x12 |
frameStatistics.cyclicTxRxErrors | always 0 | not in IG 0x0C |
frameStatistics.queuedTxRxErrors | always 0 | not 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 unavailable | IG 0x03 or IG 0x06 did not answer |
configured slave list unavailable | IG 0x07 or IG 0x09 did not answer, or answered short |
poll cycle budget exceeded | The 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"
}| Name | The read behind it |
|---|---|
frameStatistics | Master frame counters, IG 0x0C |
scannedIdentities | The scanned identity list failed entirely (IG 0x06/0x07), or at least one slave’s own identity within it did (IG 0x11) |
slaveDetail | At least one slave’s identity, init-error state and ports — IG 0x11/0x09/0x12 |
slaveErrorCounters | At 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
PollingIntervalMsbefore 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) + PollingIntervalMsAt defaults (PollCycleBudgetMs: 5000, PollingIntervalMs: 1000), taking discovery as M × 2 s
typical and (M + 5) × 2 s worst:
| Shape | Typical | Worst |
|---|---|---|
| 1 PLC, 1 master | 1 × (2 + 5) + 1 = ~8 s | 1 × (12 + 5) + 1 = ~18 s |
| 1 PLC, 4 masters | 1 × (8 + 20) + 1 = ~29 s | 1 × (18 + 20) + 1 = ~39 s |
| 4 PLCs, 2 masters each | 4 × (4 + 10) + 1 = ~57 s | 4 × (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}/slavesReturns 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
}
]
}| Field | Description |
|---|---|
physicalAddress | EtherCAT fixed address (used in URL as {address}) |
name | Decoded device type (e.g., EK1100), falling back to Slave {addr} — see the ambiguity note below |
type | Hardware type derived from the product code (e.g., EK1100, EL2808), or Unknown — see the ambiguity note below |
currentState | EtherCAT state of this slave |
isPresent | Physically present on the bus |
hasError | Slave is signaling an error |
identityMatch | true 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 |
crcErrorSum | Total 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
2with a product code whose terminal number is0.
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 with | crcErrorCount: 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: true | there 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.
| Field | Meaning |
|---|---|
vendorName | Vendor name from the ESI file’s <Vendor> element |
nameEn | English 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 |
nameDe | German 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 |
group | Device group name, resolved via the device’s <GroupType> |
url | Vendor 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:
esiStatus | esi | Meaning |
|---|---|---|
resolved | populated | The device was found in the ESI set |
notConfigured | null | No ESI directory is configured, or it does not exist |
identityUnavailable | null | The 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) |
notFound | null | The identity is absent from the ESI set, or the search hit its budget |
readFailed | null | An 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:
Cached failures persist until restart. A device that resolved
notFoundorreadFailedkeeps that status for the process lifetime. Correcting or adding an ESI file therefore needs an app restart to take effect.The lookup budget is checked between files, not mid-file. A single very large family file can overrun
LookupBudgetMson its own.Candidate ranking can miss badly, and not only for non-Beckhoff vendors. Files are searched in an order derived from the inferred
typestring. For a non-Beckhoff vendor that string isUnknownorVendor(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
xwildcard 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 inBeckhoff ELx9xx.xml, not theBeckhoff EL19xx.xmlthe ranking bets on first; becausexsorts after digits,ELx9xxfalls 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 namesLookupBudgetMsso 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}/topologyReconstructs 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}/syncunitsNot 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-countersClears 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).
| Status | Meaning |
|---|---|
204 No Content | Counters cleared |
404 Not Found | Unknown PLC, master, or slave |
502 Bad Gateway | The 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.
| Status | Code | Meaning |
|---|---|---|
200 | — | Object read |
400 | VALUE_OUT_OF_RANGE | Index or subindex is not parseable / out of range |
404 | COE_NOT_SUPPORTED | Slave has no mailbox — it cannot serve CoE at all |
404 | COE_OBJECT_NOT_FOUND | Slave supports CoE but lacks that object or subindex |
404 | SLAVE_NOT_FOUND / MASTER_NOT_FOUND / PLC_NOT_FOUND | Unknown target |
502 | ADS_ERROR | Transport 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
}
}
}
}| Field | Description | Default |
|---|---|---|
CoeTimeoutMs | ADS timeout for one SDO read — the latency paid when a slave cannot answer | 3000 |
CoeMaxObjectBytes | Largest object body accepted from a slave | 1024 |
Writing objects (SDO download) is not supported.
Real-Time Notifications (SignalR)
Hub: /api/plcs/{plcId}/ethercat/wsRequires 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
suffix — connection.on("MasterStateChangedEvent", ...). Earlier revisions of this page listed
these names without the suffix; a handler registered under those names never fired.
| Event | Payload | Description |
|---|---|---|
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
}
}
}
}| Field | Description | Default |
|---|---|---|
PollingIntervalMs | How often to poll diagnostics data | 1000 |
CrcErrorThreshold | CRC count that triggers notification | 100 |
EnableNotifications | Enable SignalR events for this PLC | true |
PollCycleBudgetMs | Wall-clock bound on one master’s poll cycle — see Poll cycle budget | 5000 |
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
}
}
}| Option | Default | Meaning |
|---|---|---|
Directory | unset | Directory of vendor ESI XML files. Unset, blank or non-existent disables ESI enrichment entirely |
LookupBudgetMs | 5000 | Wall-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:
| IG | IO | Description |
|---|---|---|
0x03 | 0x100 | Master state (uint16) |
0x06 | 0x0 | Slave count (uint16) |
0x07 | 0x0 | Slave address list (uint16[]) |
0x09 | 0x0 | All slave states (byte[] — 2 per slave) |
0x09 | slave addr | Per-slave state + link |
0x0C | 0x0 | Frame counters (5× uint32) |
0x11 | slave addr | Slave identity (4× uint32: vendor, product, revision, serial) |
0x12 | slave addr | CRC errors per port (4× uint32) |