Troubleshooting
Indexed by what you actually see — the exception type, the error text, or the behaviour. Each entry names the likeliest cause first, then the rarer ones, with a link to the long-form explanation.
Exceptions
TargetMachineNotFoundon every operationDeviceSymbolNotFoundon one symbolAdsConnectionUnavailableExceptionTimeoutException- An RPC call fails as an unknown method
- A symbol browse times out at 5 s
UnknownPlcTargetException- The host fails at startup with options validation errors
PlcAlarmShapeExceptionPlcAlarmAcknowledgeExceptionnaming an enum member- A test assertion fails on a value that looks equal
- A raw-channel read answers
DeviceInvalidOffsetin simulation
Wrong behaviour, no exception
- Alarms never arrive
- Every alarm re-raises after a restart or deployment
AcknowledgeAsyncreturnsfalse- A simulated symbol reports
STRINGwhere the PLC would reportDINT - A struct read fails naming one member
- A struct reads correctly in simulation and garbage on hardware
- Enumerating keyed connections finds nothing — or throws
- A subscription callback never fires
- The health check is green but a target is not being monitored
- Shutdown hangs
TargetMachineNotFound on every operation
Likeliest cause: the machine running your application has no AMS route to the PLC. On a host
without a TwinCAT installation the embedded router starts with an empty route table, and without a
route it cannot reach a remote PLC at all — every operation answers TargetMachineNotFound. Add
the route under AmsRouter:Routes (configuration).
This is invisible on a Windows machine with TwinCAT installed, where the OS router already holds
the routes — which is why it classically appears the day the application moves to a container or
a fresh server.
Also check, in order:
- The PLC has no return route to you. AMS routing is bidirectional: the PLC's own route table
(TwinCAT: System → Routes) must name your host's AMS Net ID (
AmsRouter:NetId) and IP. - A wrong
AmsNetIdon the target. The Net ID is not derived from the IP address; check it on the PLC under System → Routes → NetId Management.
DeviceSymbolNotFound on one symbol
Likeliest cause: the symbol path does not exist on that PLC — a typo, or a PLC program change
that renamed or removed it. Nothing on the client side can check a path against the PLC, which is
why PlcSymbol<T> exists: one place per symbol to correct.
Also check, in order:
- Wrong ADS port. Port 851 is the first TC3 PLC runtime; a second runtime lives on 852. A valid path asked of the wrong runtime is simply not found.
- The variable is not in the symbol table. Local variables of a method, and variables excluded
with
{attribute 'symbol' := 'none'}, produce no symbol. - Browse before you guess:
SearchSymbolsAsyncor theAdsSymbolDumpdiagnostic shows what the PLC actually exposes.
AdsConnectionUnavailableException
Likeliest cause: you read immediately after startup, before the target ever connected.
BuildAndStartAsync (and host startup) return as soon as the pool is started; a real target
connects only once the embedded router is ready. An operation issued in that window waits
TimeoutMs, then throws exactly this. Gate startup reads with
WaitForConnectedAsync.
Also:
- The target is mid-outage. This is the wait-then-throw
contract: the operation waited
TimeoutMsfor a connection and none was published. The connection facade stays valid; retry whenConnectionStateChangedreportsConnected. - The host is shutting down. After the pool stops, operations fail fast with this exception without waiting.
TimeoutException
Likeliest cause: the round-trip genuinely exceeded TimeoutMs — a slow symbol, a large struct
decode, or a congested network. This exception means the target was connected and the
hardware did not answer in time (an unavailable connection throws
AdsConnectionUnavailableException instead; the two are never conflated). Scope a longer bound to
the slow call with WithTimeout rather than
raising TimeoutMs for the whole target.
On raw channels, remember the timeout
bounds each attempt: the default 5000 ms with RetryCount: 1 takes up to 10 s before
throwing.
An RPC call fails as an unknown method
Likeliest cause: the PLC method is missing {attribute 'TcRpcEnable'}. A method without it is
not reachable over ADS at all, and the call fails as an unknown method however correct the
instance path and the name are. This applies to your own
InvokeRpcMethodAsync calls and equally to alarm
acknowledgement, which is an RPC under the hood: when AcknowledgeAsync raises
AdsErrorException on a target whose alarms otherwise arrive normally, check the attribute on the
method declaration first — it is the likeliest cause and the cheapest to rule out.
Also check, in order:
- The instance path names the wrong function block. For alarms, the block's path is derived by
trimming the last segment off
SymbolPath, which is wrong whenever the alarm array is not a member of the acknowledging block — setAcknowledgeInstancePath. - The method name is misspelled (
AcknowledgeMethodfor alarms, themethodNameargument for your own calls). ADS reports a missing method the same way as an unexported one.
A symbol browse times out at 5 s
Likeliest cause: a WithTimeout scope built for quick reads is being reused for the browse.
Browses are normally bounded by SymbolBrowseTimeoutMs (default 30 s, six times the default
TimeoutMs) precisely because uploading a symbol table is slow — but a
WithTimeout scope replaces both configured
bounds, so a 5 s scope silently shortens the browse to 5 s. Browse on the unscoped connection, or
give the browse its own scope.
Also:
- The symbol table is genuinely huge. A root
GetSymbolTreeAsyncprojects every symbol on the PLC. Browse one level at a time withGetSymbolsAsync(parentPath, includeChildren: false), or raiseSymbolBrowseTimeoutMs.
UnknownPlcTargetException
The id you asked for is not configured — the exception lists every id that is. Likeliest
cause: a spelling difference between the code and the PlcTargets key (ids are case-insensitive,
so it is not a casing problem). Note that keyed injection and GetKeyedService throw this too —
the registration is a factory, so even the "optional" path runs it. Use
pool.TryGetConnection when the id may legitimately not be
configured.
The host fails at startup with options validation errors
This is the library working as intended, and the message names every bad entry at once. All
configuration is validated at boot: malformed AMS Net IDs (999.1.1.1.1.1 fails rather than being
laundered to 0.1.1.1.1.1), out-of-range ports, non-positive timeouts, malformed
raw-channel seed entries, and alarm targets with no
SymbolPath. Fix what the message names; the configuration reference has the
rules per key. Two entries worth knowing about because the symptom precedes the rule:
"Port": "0x851"in a raw-channel seed is port 2129, not 851 — hex is accepted, so it binds fine and seeds a channel nobody reads. The canonical TC3 runtime port is decimal851.- A seed
Portthat is not a number, or a slot written as a bare value, is discarded by the configuration binder before validation can see its contents — which is exactly why an entry with a missing port fails startup by name instead of silently seeding nothing.
PlcAlarmShapeException
The PLC's ST_ErrorEntry no longer matches what the alarms package binds — a member was
removed or retyped, typically after a PLC program update. The exception names the member and the
symbol path. The snapshot is dropped whole, the outstanding set keeps its last good reading, and
the subscription recovers on the next well-formed notification; the report fires once per outage,
not once per cycle. See PLC alarms → Fault tolerance. A bad value
in one slot (an out-of-range ErrorType, a nonsense timestamp) is deliberately not treated as
a shape mismatch and only logs a Warning.
PlcAlarmAcknowledgeException naming an enum member
The PLC's acknowledge method answered with a deaReturnType member this package does not map —
only SUCCESS (→ true) and NOT_FOUND (→ false) are mapped; anything else raises, naming the
member, because inventing a meaning for LOCKED or a vendor-specific state would be a guess. The
resolution is by name, not number, so a PLC-side renumbering does not break it. What the named
member means is the PLC program's business — read its declaration of deaReturnType. A vendor
whose PLC acknowledges differently can register its own
IPlcAlarmDialect.
A test assertion fails on a value that looks equal
Expected a write of 23.5 (Single) to "GVL.Setpoint" on plc1, but 2 write(s) were recorded:
[0] 23.5 (Double)
Likeliest cause: the CLR types differ. Comparison is Equals-based and therefore
type-sensitive — a boxed float 23.5 does not equal a boxed double 23.5, which is why
PlcAssertionException prints the types. Match the type the code under test actually writes
(23.5 is a double; write 23.5f to assert a float). See
Testing → Assertions.
A raw-channel read answers DeviceInvalidOffset in simulation
Likeliest cause: the slot was never seeded. An unseeded read answers DeviceInvalidOffset —
the same code real hardware gives for an unknown index — so your error handling is exercised
rather than handed fake success. Seed the slot with the bytes your
decoder expects. If you did seed it, check the seed entry actually matched: the Net ID matches
after normalisation, but the port must be exactly right — and remember
"0x851" is 2129.
Alarms never arrive
Likeliest cause: the handler was attached too late. The monitor registers its subscriptions
during host startup, and ADS delivers the whole outstanding set inside StartAsync — an
AlarmChanged handler attached after the host starts silently misses every alarm the PLC was
already holding. Register handlers with
AddAlarmHandler, which is resolved before any subscription exists and
cannot be too late.
Also check, in order:
- The target is not configured for monitoring. A target absent from
PlcAlarms:Targetsis simply not monitored — the package is opt-in per target (configuration). - You asked before the host started. Before
StartAsyncnothing is subscribed, soGetOutstanding()is empty (andAcknowledgeAsyncreturnsfalse). - The PLC was down at boot and the
SymbolPathis wrong. A target that was offline at startup has its registration retried on every reconnect — including a bad path, which would have failed startup had the target been reachable. Watch the log for a target that never reportsMonitoring alarms on …; the retry failure is logged atError. See PLC alarms → Restarts, offline targets and ordering.
Every alarm re-raises after a restart or deployment
This is by design, and there is no way around it inside the package. The outstanding set lives
in memory and starts empty; ADS delivers one notification on registration, so the first snapshot
after a restart reports every alarm the PLC is holding as newly Raised — a two-day-old alarm is
indistinguishable from one raised while the host was down. Forwarding Raised straight to a pager
pages the whole outstanding set on every deployment: deduplicate downstream on Key plus
PlcTimestamp, or suppress the first snapshot after start. See
PLC alarms.
AcknowledgeAsync returns false
false means no such alarm among the outstanding set (the PLC answered NOT_FOUND, or the
monitor never had the alarm). Likeliest cause: the key does not match — identity is the PLC's
sKey spelling, treated as opaque, so pass exactly what alarm.Key reported. Also possible: the
host has not started yet (nothing is subscribed, so nothing is outstanding), or the alarm already
ended. A false is a lookup miss; a failure to acknowledge raises instead — see
PlcAlarmAcknowledgeException and
the unknown-method entry.
A simulated symbol reports STRING where the PLC would report DINT
Likeliest cause: the value was seeded as a bare JSON scalar. JSON configuration is
string-typed, so "MAIN.Speed": "1500" seeds the string "1500" — typed reads still convert,
but a metadata read reports STRING and a strict consumer notices. Declare the PLC type to get a
faithful stand-in: "MAIN.Speed": { "value": 1500, "type": "DINT" }. The type is never inferred
from the value's content. See Simulation → Seeding initial values.
A struct read fails naming one member
Likeliest cause: your .NET type declares a member the PLC's struct does not supply. Binding
requires every member of the target type to be present in the decoded tree — a missing one fails
by name rather than silently defaulting, because a type that disagrees with the PLC is what you
want to hear about. Extra members on the PLC side are ignored. To read a subset, read the symbol
as IReadOnlyDictionary<string, object?> instead. See
Reading a PLC struct into a .NET type.
A struct reads correctly in simulation and garbage on hardware
Likeliest cause: member order. Simulation binds by name (a decoded tree has no memory
layout); real hardware hands T to Beckhoff's marshaller, which maps PLC memory by declaration
order. A simulated target catches a misspelled or mistyped member and cannot catch a mis-ordered
one. Verify the .NET type's field order against the PLC declaration. See
the note under struct reads.
Enumerating keyed connections finds nothing — or throws
GetKeyedServices<IAdsConnection>(KeyedService.AnyKey) cannot enumerate targets: on .NET 8
and 9 it throws (the container passes the AnyKey sentinel to the factory, and a sentinel names
no target), and on .NET 10 it returns empty. Injecting IEnumerable<IAdsConnection> likewise
yields nothing — the keyed registration deliberately does not leak into the unkeyed enumeration.
Use pool.GetAllConnections(), the one supported way to
enumerate.
A subscription callback never fires
Check, in order:
- On a simulated target: the value never changed. Simulated subscriptions fire on changed
writes — the change test is
Equals-based and type-sensitive, so writing adouble23.5 over a seededfloat23.5 is a change, and writing the same boxed value is not.Seed/SetInitialValuesdeliberately never fire; useWriteto drive a change (Testing → Seed, write, and what got recorded). - The notification value was
nulland yourTis a value type. That notification is dropped with a logged Warning (Subscriptions). - A UI update that never lands is a threading problem, not a delivery problem. Callbacks fire
on a background thread; marshal to the UI thread yourself, or use the Rx companion with
.ObserveOn(...).
The health check is green but a target is not being monitored
AddTwinCatAdsAlarmHealthCheck reports alarm severity and nothing else — a target still
waiting for its first connection has no alarms and looks healthy, indistinguishable from one that
is connected and genuinely quiet. Register the core
AddTwinCatAdsHealthCheck alongside it: connectivity comes from
the core check, severity from the alarms check, and the two together are the full picture.
Shutdown hangs
Likeliest cause: a blocking dispose on a UI thread, or a hosted service that hangs in
StopAsync. The pool handle is IAsyncDisposable because stopping the router and reconnect
loops is genuinely asynchronous — a WinForms/WPF exit path that cannot await should
block explicitly. And note the no-host builder's private container
has no HostOptions.ShutdownTimeout: a service you registered through ConfigureServices
that hangs in StopAsync hangs DisposeAsync with it
(the hosted-service-runner caveats).