ads

package module
v2.3.4 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 18, 2026 License: MIT Imports: 25 Imported by: 2

README

go-ads

A pure Go library for communicating with Beckhoff TwinCAT PLCs using the ADS (Automation Device Specification) protocol.

v2.2 breaking change: every RPC method takes a context.Context as the first argument. NewSession accepts a typed AMSEndpoint plus options instead of 7 positional arguments. Connect takes ctx instead of a local-mode bool (use WithLocalMode()). Symbol is unexported (use SymbolView). Update.Stale is now *StaleInfo. See CHANGELOG.md for the full migration sketch.

v2.1 breaking change: the previous Connection type has been renamed to Session, and the raw RPC surface has been split off into a separate Client type.

Features

  • Connect with only an IP — the target AmsNetId and AMS port are probed from the device when omitted (no route, no credentials needed for the probe).

  • Connect to TwinCAT 2 and TwinCAT 3 PLCs over TCP

  • Two-layer API: a Beckhoff-equivalent raw Client for one-shot consumers and a managed Session that adds caching, notification persistence, and auto-reconnect

  • Read/write PLC symbols by name

  • Batch read multiple symbols in a single round-trip (SumRead)

  • Subscribe to symbol change notifications (single and batch)

  • Automatic symbol table and datatype discovery

  • Auto-reconnect with configurable backoff and notification re-subscribe

  • Smart AMS route registration (probe-first, credential fallback)

  • Stale handle detection across reconnects (epoch counter)

  • Disconnect/reconnect event callbacks

  • Symbol version change detection and refresh

Install

go get github.com/RuneRoven/go-ads/v2

Quick start

package main

import (
	"context"
	"fmt"
	"time"

	ads "github.com/RuneRoven/go-ads/v2"
)

func main() {
	ctx := context.Background()

	target, _ := ads.NewAMSAddress("5.1.2.3.1.1", 851)
	sess, _ := ads.NewSession(ctx, ads.AMSEndpoint{IP: "192.168.1.100", Port: 48898, AMS: target},
		ads.WithRoute("my-route", "Administrator", "1"),
		ads.WithRequestTimeout(5*time.Second),
	)
	if err := sess.Connect(ctx); err != nil {
		panic(err)
	}
	defer sess.Close()

	// Symbols are resolved on-demand — no full discovery needed.
	value, _ := sess.ReadFromSymbol(ctx, "MAIN.myVar")
	fmt.Println("Value:", value)

	// Optional: load full symbol table for listing / struct access.
	sess.LoadSymbols(ctx)
	symbols, _ := sess.ListSymbols()
	fmt.Printf("Total symbols: %d\n", len(symbols))
}

Target discovery — connecting without an AmsNetId

A wrong or absent NetID is the most common ADS misconfiguration, and the hardest to recognise: the router accepts the TCP socket and then silently drops every request, which looks nothing like an addressing mistake. So the address is optional — omit AMS and the device is asked for it.

// No AMS field at all: NetID and AMS port both come from the device.
sess, err := ads.NewSession(ctx, ads.AMSEndpoint{IP: "192.168.1.100"},
    ads.WithRoute("my-route", "Administrator", "1"),
)

The probe is the AMS router's identify service — UDP on the route-registration port (48899), service id 1. It registers nothing, needs no route and no credentials, and it answers before any route exists, which is what makes it usable to bootstrap a connection rather than only to inspect one. Verified against TwinCAT 2.10 (CX), TwinCAT 3.1.4024 (CX) and TwinCAT 3.1.4026 on TC/RTOS, all three answering a request that carries a zero source NetID — so discovery needs no local configuration either.

What you get, logged at Info so an operator learns the device's identity from one line even when they supplied nothing:

INFO discovered target AMS address host=192.168.3.118 netID=5.66.133.203.1.1
     port=851 hostName=CX-4285CB twinCAT=3.1.4024

Cold bootstrap, measured end to end against a PLC with an empty route table and a fresh boot (~1s):

discovered target AMS address netID=5.66.133.203.1.1 port=851 twinCAT=3.1.4024
route probe failed, registering route      <- no route yet, detected by trying
registering route localNetID=192.168.3.52.1.1 routeName=my-route
route registration successful
TCP reconnected after route registration
connected: symbol version 1

Two deliberate refusals:

  • The AMS port is inferred from the reported TwinCAT version, never guessed. If a device reports no version, NewSession fails and tells you to set AMS.Port explicitly rather than planting 851 and addressing a runtime that may not exist. Multi-runtime projects (811, 852, …) must set it anyway — the inferred port is logged so that is visible.
  • The response is the identity of the router answering at that IP, not "the NetID of the PLC behind it". On an embedded target (a CX, where router and runtime are the same device) those coincide. On an engineering PC or a gateway fronting other PLCs it is that machine's NetID and the PLC you want is an entry in its route table. Nothing in the response distinguishes the two, so nothing here pretends to.

Without credentials and without a route, Connect fails — and says why, in the error itself rather than only in a log line:

transport dropped during connect to 192.168.3.118: ads: client transport closed
(a reset right after TCP connect means one of: no route is registered on the PLC
for our NetID (192.168.3.52.1.1), the target NetID (5.66.133.203.1.1) does not
exist on this PLC, the route credentials were rejected, or AMS port 851 addresses
no running runtime)

Use WithTargetCheck to verify a NetID you did supply against the device — see Connection options.

Routes are matched by NetID, not by name

A route entry on the PLC authorises a source AmsNetId (and the IP it comes from). The name is a PLC-side label, so it is needed only to create a route — never to use one that already exists:

// Already-provisioned device: no NetID, no route name, no credentials.
sess, err := ads.NewSession(ctx, ads.AMSEndpoint{IP: "192.168.1.100"})

Consequences worth knowing, all measured on TwinCAT 2.10 and TwinCAT 3.1.4024:

  • Passing a different route name does not create a second entry. With a route already present for your NetID the probe succeeds and registration is skipped — logged as route already exists on PLC, skipping registration. Duplicate entries are one of the ways these devices go mute, so this matters more than it looks.
  • What must line up is your source NetID. Without WithLocalAMS the library derives it from the host IP, so a pre-registered route has to have been created for that NetID. If it was not, the PLC resets the connection and the error names it: no route is registered on the PLC for our NetID (…).
  • A single failed probe is not taken as proof a route is missing — it is retried once before falling back to registration, because the first transport attempt after a PLC boot is unreliable (observed on TC2).

Cold bootstrap timings against an empty route table, for reference: ~1.0s on TwinCAT 3.1.4024, ~1.2s on TwinCAT 2.10 (including the retry above).

If UDP 48899 is blocked

The identify service and route registration share that port, so a firewall in front of it removes both — while ordinary ADS traffic, which is TCP 48898 only, keeps working. Measured behaviour:

capability UDP 48899 blocked
NetID / port discovery unavailable — pass AMS explicitly
route registration unavailable — pre-register the route on the PLC, or pass WithSkipRouteRegistration()
WithTargetCheck verification skipped, never fatal (see below)
reads, writes, notifications unaffected

Discovery fails fast rather than handing back a session that would silently drop every request — three attempts inside a 3s budget, then NewSession returns:

ads: NewSession: target AMS address incomplete and discovery failed (set remote.AMS
explicitly if the device does not answer the identify service): identify 192.0.2.1:
no answer after 3 attempts: ... i/o timeout

Verification is different on purpose: an unanswered probe is never a failure, in any TargetCheck mode. A Windows TwinCAT host was observed serving ADS on TCP with UDP 48899 firewalled off, and refusing those sessions over a check that could not run would trade a real capability for a diagnostic. Only a definite mismatch is reported. The skip is logged at Info, so silence never reads as "verified".

Two layers

The library exposes two types. Pick the one that fits your consumer.

Client — raw RPC (Beckhoff-equivalent)

Client is a thin wrapper around one TCP connection. Each method is a single ADS round-trip. No symbol cache, no notification persistence, no auto-reconnect. If the transport drops, every subsequent call returns ErrTransportClosed and the caller reconstructs a new Client.

Use this for one-shot consumers — CLI tools, web ADS browsers doing a quick probe, scripts that send a single command and exit.

ctx := context.Background()

client, err := ads.Dial(
    "192.168.1.100", 48898,
    ads.AMSAddress{NetID: [6]byte{5, 1, 2, 3, 1, 1}, Port: 851},
    ads.AMSAddress{NetID: [6]byte{192, 168, 1, 50, 1, 1}, Port: 30000},
    5*time.Second,
    ads.WithClientLogger(slog.Default()),
)
if err != nil {
    panic(err)
}
defer client.Close()

info, _ := client.ReadDeviceInfo(ctx)
fmt.Printf("Device: %s\n", string(info.DeviceName[:]))

handle, _ := client.GetHandleByName(ctx, "MAIN.myVar")
defer client.ReleaseHandle(ctx, handle)

data, _ := client.Read(ctx, uint32(ads.GroupSymbolValueByHandle), handle, 4)
fmt.Printf("Value bytes: %v\n", data)
Session — managed (long-running consumers)

Session wraps a Client and adds the value-add for long-running consumers: symbol cache, name-based read/write, notification persistence with auto-resubscribe, auto-reconnect with backoff, online-change handling, lifecycle callbacks. Session does NOT promote Client methods — call sess.ReadFromSymbol(name) for cache-aware access; raw consumers construct a separate *Client.

Use this for daemons, message brokers, or anything that should survive a network blip without manual intervention.

ctx := context.Background()
target, _ := ads.NewAMSAddress("5.1.2.3.1.1", 851)
sess, _ := ads.NewSession(ctx, ads.AMSEndpoint{IP: "192.168.1.100", Port: 48898, AMS: target},
    ads.WithRoute("my-route", "Administrator", "1"),
    ads.WithAutoReconnect(true),
    ads.WithOnReconnect(func() { log.Println("back online") }),
)
sess.Connect(ctx)
defer sess.Close()

// Cache-aware read (resolves on-demand, then caches for the connection's lifetime).
value, _ := sess.ReadFromSymbol(ctx, "MAIN.myVar")

// Persistent subscription (resubscribes automatically after a reconnect).
ch := make(chan *ads.Update, 64)
sess.AddSymbolNotification(ctx, "MAIN.bCounter", 100*time.Millisecond, 100*time.Millisecond,
    ads.TransModeServerOnChange, ch)
for update := range ch {
    if update.Stale != nil {
        fmt.Println("STALE:", update.Stale.Reason, update.Variable, update.Value)
        continue
    }
    fmt.Println(update.Variable, update.Value)
}

Example CLI

A ready-to-run example is included with two demo modes (Session-managed and raw Client). Selection is interactive by default, or via ADS_DEMO:

cd examples/cli
ADS_PLC_IP=192.168.1.100 ADS_TARGET_AMS=5.1.2.3.1.1 \
ADS_SYMBOL_NAME=MAIN.bCounter ADS_DEMO=session go run .

See examples/cli/README.md for the full env-var reference.

Connection options

All options are passed to NewSession and have sensible defaults:

target, _ := ads.NewAMSAddress(netid, 851)
sess, _ := ads.NewSession(ctx, ads.AMSEndpoint{IP: ip, Port: 48898, AMS: target},
    // Route registration — probe first, register with credentials only if needed
    ads.WithRoute("my-route", "Administrator", "1"),

    // Custom logger (default: slog.Default())
    ads.WithLogger(myLogger),

    // Reconnect backoff strategy (default: 1s×3, 5s×3, 15s×4, then 30s cap)
    ads.WithBackoff(ads.BackoffConfig{
        InitialInterval: 1 * time.Second,
        InitialAttempts: 3,
        MidInterval:     5 * time.Second,
        MidAttempts:     3,
        SlowInterval:    15 * time.Second,
        SlowAttempts:    4,
        MaxInterval:     30 * time.Second,
    }),

    // Disable auto-reconnect (caller must call sess.Reconnect() manually)
    ads.WithAutoReconnect(false),

    // Event callbacks (run in goroutine, must not block)
    ads.WithOnDisconnect(func() { log.Println("disconnected") }),
    ads.WithOnReconnect(func() { log.Println("reconnected") }),

    // Fail reconnect if previously-resolved symbols are missing (e.g., after PLC online change)
    // maxAttempts=3 means retry 3 times before closing the connection
    ads.WithStrictReconnect(3),

    // Always register route with credentials (skip probe, for non-persistent route environments)
    ads.WithForceRouteRegistration(),

    // Override callback IP for Docker/VPN/NAT (default: derived from AMS NetID)
    ads.WithHostIP("192.168.1.50"),
)
Option Default Description
WithRoute(name, user, pass) No route Register AMS route via UDP. Probes first, registers only if needed
WithBackoff(cfg) 1s×3, 5s×3, 15s×4, 30s cap Stepped reconnect backoff with configurable tiers
WithAutoReconnect(bool) true Auto-reconnect on TCP errors. When false, sets disconnected flag only — caller must call Reconnect()
WithOnDisconnect(fn) None Callback on disconnect detection
WithOnReconnect(fn) None Callback after successful reconnect
WithStrictReconnect(n) Graceful skip (missing symbols warned + removed) Fail if on-demand symbols missing after reconnect. n = max retry attempts before closing
WithForceRouteRegistration() Probe first Always register route with credentials (skip probe). Requires WithRoute
WithHostIP(ip) Derived from AMS NetID IP the PLC uses to reach this client (Docker/VPN/NAT). Requires WithRoute
WithLogger(logger) slog.Default() Custom structured logger
WithSymbolVersionStrategy(s) SymbolVersionAutoReload Online-change handling strategy (AutoReload / Close / Ignore)
WithMaxSymbolVersionReloadAttempts(n) 3 Cap reload attempts within the sliding window (AutoReload only)
WithSymbolVersionReloadWindow(d) 60s Sliding window length for the reload-attempt cap
WithOnSymbolVersionChanged(fn) None Callback fired once per online-change detection (reason is one of the Reason* constants). Also fires with ReasonHeartbeatSilent under WithHeartbeatRecovery(HeartbeatRecoveryObserve)
WithLocalAMS(addr) NetID auto-derived; Port random in 32768-49151 Override source AMSAddress in outgoing ADS headers. The AMS port is a logical identifier inside the header, NOT the TCP source port (kernel-assigned) and NOT the TCP destination port (always 48898). Each Session randomizes by default so distinct Session instances present distinct AMS source identities to the PLC
WithLocalBindIP(ip) Unset — OS picks via routing table Pin the outbound TCP source IP. Used for multi-Session deployments on hosts with IP aliases. Invalid IP → Warn + nil (OS routing). See §Limitations for the multi-Session constraint
WithSkipRouteRegistration() Off Explicit opt-out from probe+AddRoute. Required when routes are managed externally (TC3 UI pre-registered, AmsRouterDaemon front-end). Equivalent to omitting WithRoute but explicit
WithLocalMode() Off Target the in-process TwinCAT runtime at 127.0.0.1
WithRequestTimeout(d) 5s Per-request timeout for ADS commands and initial TCP dial
WithNotificationHeartbeat(interval, missed) 2s, 5 Tune the internal cyclic subscription that detects subscriptions dying silently. interval is clamped to the ADS cycle-time limit (~400s); missed below 2 is raised to 2
WithoutNotificationHeartbeat() Heartbeat on Opt out. Saves one PLC notification handle and one 1-byte sample per interval, at the cost of not noticing a runtime restart that leaves the TCP connection up
WithNotificationSilenceTimeout(d) Derived from missed (10s) State the tolerated silence as a duration instead of a tick count; missed is derived (rounded up, floored at 2). Last of this and WithNotificationHeartbeat's missed wins
WithHeartbeatRecovery(mode) HeartbeatRecoveryImmediate What happens when the heartbeat goes silent. Immediate re-subscribes at once; Confirm waits for a second consecutive silent window before churning every handle; Observe reports (log + ReasonHeartbeatSilent callback) and leaves the rebuild to the consumer
WithRuntimeStateWatch(d) 5s How often the runtime state (RUN/CONFIG) is polled on the system service port. Was previously the heartbeat interval — the two are independent as of this release
WithoutRuntimeStateWatch() Watch on Turn the state poll off. The gates on symbol and subscription calls then fall back to permitting, so a PLC in CONFIG fails the older, more obscure way
WithAmsPeerListen(port) Off (fallback binds 48898 on demand) Listen on port for a connection the PLC opens to US. Needed for devices that treat their route to this host as a peer route and answer only on their own connection
WithoutAmsPeerFallback() Fallback on Never bind the AMS port. Use on hosts where a local TwinCAT router owns 48898
Combining options

All options are composable — no mutual exclusions. Some require others to have effect:

Option Requires Notes
WithForceRouteRegistration() WithRoute() No-op without route credentials
WithHostIP() WithRoute() Only affects route registration packet
WithBackoff() — Used in both auto and manual Reconnect()
WithStrictReconnect() — Only affects on-demand symbols (resolved via GetSymbol before reconnect)
WithOnDisconnect() / WithOnReconnect() — Fire regardless of auto/manual reconnect mode
WithAutoReconnect(false) — Backoff still applies when caller invokes Reconnect() manually
WithSkipRouteRegistration() — Overrides WithRoute. Skip wins. Useful when an options chain is built uniformly across Sessions and only some opt out
WithLocalBindIP(ip) Host must have ip aliased on a local interface before Connect OS returns "address not available" from Dial if alias missing
WithAmsPeerListen(port) — Also sets the port the automatic fallback uses. Mutually pointless with WithoutAmsPeerFallback() (the listener would never be used)
WithNotificationHeartbeat() At least one subscription The heartbeat is established alongside the first subscription and released with the last

Runtime state: CONFIG vs RUN

A TwinCAT system in configuration mode has no runtime port. Every request to it comes back with AMS error 0x06 (target port not found) — measured on TC3.1.4024 — while the system service on port 10000 stays up and answers ADSState=15 (CONFIG). So the state can be asked for, just not of the runtime.

The session polls it (at the heartbeat interval, one small request over the connection it already has) and behaves accordingly:

Behaviour
Connect Succeeds. The session is usable and waits; there is simply no runtime to talk to yet
LoadSymbols, AddSymbolNotification(s) Refuse, with an error wrapping ErrRuntimeNotRunning that names the ADS state
IsClosed() Stays false — nothing is wrong
Back in RUN The poll notices and the same calls start working, with no reconnect and no rebuild
if _, err := sess.AddSymbolNotifications(ctx, configs, ch); errors.Is(err, ads.ErrRuntimeNotRunning) {
    // Not a failure to give up on: the PLC is in CONFIG. Try again later.
    state, _ := sess.RuntimeState(ctx) // ads.ADSStateConfig, ADSStateStop, ...
    log.Printf("PLC not running (ADS state %d), will retry", state)
}

Note the transition is usually CONFIG → STOP → RUN with a few seconds between, so gate on "is it RUN", not "is it CONFIG": the runtime port exists during STOP but is not executing.

The gate only ever fires on a positive reading. A device that does not serve the system service port behaves exactly as before, and the poll gives up after five consecutive failures.

Diagnosing a dropped connection

A TCP drop has two very different meanings, and they used to look identical — both surfaced as ErrTransportClosed with a hint naming the route. The session now classifies them by whether the connection ever carried an AMS frame, and reports the verdict in the log for every drop, and as a sentinel on the error Connect returns:

Verdict Sentinel What it means Where to look
Never served ErrRouteNotServed The connection was dropped without carrying a single frame Route for our NetID, target NetID, credentials, AMS port, or another client on this host IP holding the router's single per-host TCP slot
Established drop ErrEstablishedDropped The connection had been delivering frames and was then dropped Not the route — it was demonstrably being served. Eviction by another client on this IP, a runtime restart, or the network path
if err := sess.Connect(ctx); err != nil {
    switch {
    case errors.Is(err, ads.ErrEstablishedDropped):
        // the route worked a frame ago; do not go hunting route tables
    case errors.Is(err, ads.ErrRouteNotServed):
        // addressing or route problem
    }
}

Scope of the sentinels: they are attached to the error Connect returns when the transport drops during a connect. A drop on an already-running session is handled by the reconnect machinery and does not surface an error to the caller — RPCs issued in that window still fail with ErrTransportClosed, and the verdict for that drop is in the log line below.

Both log lines carry localPort, framesPrimary, framesPeer and uptime at INFO, so a drop can be correlated against a packet capture after the fact. Note framesPeer: on a device that answers only on the connection it opens to us (see §Peer-route devices), the primary socket carries no frames at all, so the verdict counts frames on either socket.

A connection that keeps dropping is throttled across reconnect cycles, not just within one: anything that fails to stay up for 60s counts as a flap and escalates through the WithBackoff tiers, up to MaxInterval (30s by default). Lower MaxInterval if stream continuity during a flapping episode matters more than sparing the PLC's socket table.

Peer-route devices

Some devices treat their route to this host as a peer route: they accept our TCP connection, process our requests, and then send every response over a connection they open to us on 48898. Measured on TC3.1.4026 (TC/RTOS); TC2 2.10 and TC3.1.4024 answer on our connection instead. A client that cannot accept the inbound connection sees every request time out, and Beckhoff's own Linux AdsLib never listens, so it cannot talk to such a device at all.

Nothing needs configuring: when a Connect proves the device is otherwise silent, the session binds the AMS port, discovers the device is answering there, and carries on — announced at WARN, because a device in this state is worth an operator's attention. WithAmsPeerListen(port) picks a different port; WithoutAmsPeerFallback() refuses to bind at all, for hosts where a local TwinCAT router owns 48898.

The fact is remembered per process, keyed by host and port: learning it costs a probe timeout plus the route-activation budget (~18s), and every later session to the same device skips straight to listening (~1s) and registers no route it does not need.

Notifications and backpressure

Subscriptions that die silently

Restarting a TwinCAT runtime, or toggling CONFIG and back, kills every notification a client holds without anything observable happening: measured on TC3.1.4024 across CONFIG → RUN with no program change, three runs including a fully passive listener, the TCP connection survives, the symbol version is unchanged, ADS state reads back identical, no error and no terminal sample arrives — and the subscriptions never deliver again. 210 samples, then silence.

Silence alone proves nothing either, because an on-change subscription on a constant symbol is legitimately silent forever. So the session keeps one internal CYCLIC subscription on the symbol-version group: TwinCAT pushes those on a timer regardless of change, which makes its silence conclusive. On that same transition the beat and the caller's samples stop in the same second.

Missing five intervals (default 2s each) means the subscriptions are treated as dead and re-established from the stored configs — no polling, no caller involvement, and the PLC does the sending. Cost: one notification handle and one byte per interval. Silence is counted in ticks of a monotonic ticker, so a wall-clock step (an RTC-less box syncing NTP, a resumed VM) cannot fake it.

Recovery backs off as it fails, so a runtime left in CONFIG for an hour costs a handful of attempts rather than one per interval, and the subscriptions stay on file throughout.

Notification delivery is non-blocking: if the receiver's channel is full, the notification is dropped and a warning is logged. This prevents goroutine accumulation and ensures the receive pipeline is never stalled by a slow consumer.

Sizing your channel buffer:

// Small buffer — fine for low-frequency or on-change notifications
ch := make(chan *ads.Update, 64)

// Large buffer — recommended for high-frequency cyclic notifications
// (e.g. 100 symbols × 10ms cycle = 10,000 notifications/sec)
ch := make(chan *ads.Update, 4096)

If you see "notification dropped (channel full)" warnings, either:

  1. Increase the channel buffer size
  2. Consume notifications faster (dedicated drain goroutine)
  3. Reduce the notification cycle time on the PLC side
Handle hygiene across reconnects and crashes

The library actively prevents PLC notification-handle accumulation (Beckhoff/ADS #268) via three complementary strategies:

  1. Orphan-Delete. When a notification arrives for a handle the Session no longer tracks (typically leaked by a prior process crash), the library asynchronously issues a best-effort Delete RPC for that handle. Throttled, sem-bounded, race-aware.
  2. Auto-reload pre-delete. On SymbolVersionAutoReload, the library snapshots all current handles, wipes them locally, deletes them PLC-side, then reloads and re-subscribes. Prevents handle retention across online-change boundaries.
  3. Reconnect pre-delete. On TCP reconnect, the library snapshots pre-reconnect handles, deletes them on the new transport, then re-subscribes with fresh handles. 0x715 DeviceClientUnknown (PLC dropped client identity) is treated as cleanup-success.

No user action needed; the strategies fire automatically. Documented in detail in IMPLEMENTATION.md → Notification handle hygiene.

Limitations

One Session per source IP per target PLC

TwinCAT PLCs enforce a single active TCP connection per source IP on their ADS server (TCP/48898). This is a server-side constraint outside the library's control. Documented in Beckhoff/ADS#49, Beckhoff/ADS#72, jisotalo/ads-client#47.

What this means concretely:

  • ✅ One process, one Session to one PLC — supported, the normal case.
  • ✅ One process, multiple Sessions to different PLCs — supported (each PLC sees one client).
  • ❌ Multiple Sessions to the same PLC from the same host IP — second Session's TCP gets reset; if it sends AddRoute UDP, the first Session's existing TCP also gets closed by the PLC. This holds regardless of distinct AMS NetIDs, distinct AMS ports, distinct route names, or whether routes are pre-registered.
  • ❌ Multiple processes on the same host, each opening a Session to the same PLC — same constraint; whichever process connects last wins.

Workarounds for multi-process / multi-Session-same-PLC:

  1. Distinct source IPs. Deploy each process behind its own network namespace / container with bridge networking, so each has a unique IP from the PLC's perspective.
  2. WithLocalBindIP(ip) if the host has multiple NICs or IP aliases — pin each Session to a distinct local IP. Requires admin-configured network setup.
  3. Local AMS router daemon (Beckhoff's recommended pattern) — run Beckhoff.TwinCAT.Ads.TcpRouter or the open-source AmsRouterDaemon on the host; client processes connect to 127.0.0.1:48898 and the daemon multiplexes a single connection to the PLC. The library does not bundle a router — it is a separate sidecar.
Notification handle limits

Beckhoff documents ~550 notification handles per AMS port. The library does not enforce this cap; subscribing beyond it will fail at the PLC with 0x716 NoMoreHandles ("no more notification handles available"). Coalesce subscriptions per process or split across distinct AMS ports if you need more.

Process image I/O (experimental)

Warning: Direct process image access bypasses the symbol table and writes raw bytes to I/O memory. Writing to the wrong offset can cause unexpected physical output changes (motors, valves, actuators). The PLC runtime may overwrite your changes on the next scan cycle. For normal operation, use symbol-based access (sess.ReadFromSymbol/sess.WriteToSymbol).

Process image methods live on *Client only — they are pure wire ops with no cache or notification dependency. Session users who need them construct a raw *Client via Dial alongside their Session (or use Dial exclusively if they never need cache-aware features).

// Read 4 bytes from input image at byte offset 0
data, _ := client.ReadProcessInput(ctx, 0, 4)

// Read a single input bit (byte 2, bit 3)
val, _ := client.ReadProcessInputBit(ctx, 2, 3)

// Write to output image (use with extreme caution)
client.WriteProcessOutput(ctx, 10, []byte{0xFF})
client.WriteProcessOutputBit(ctx, 10, 0, true)

// Query input image size
size, _ := client.ReadProcessInputSize(ctx)

Development

Prerequisites
Before pushing

Run all checks locally — this mirrors what CI does on every PR:

make all        # format, lint, vet, test

Or run individual targets:

make fmt        # format code (gofumpt + goimports)
make lint       # run golangci-lint
make vet        # run go vet
make test       # run tests
make test-race  # run tests with race detector
make test-cover # run tests with coverage report
make build      # build all packages
Network requirements
Port Protocol Direction Purpose Configurable
48898 TCP Client → PLC ADS data (commands, responses, notifications) Yes (NewSession port param)
48899 UDP Client → PLC AMS route registration (WithRoute) No (Beckhoff fixed)

Both ports must be open in firewalls between the client and PLC. If only TCP 48898 is open, ADS works with pre-existing routes but WithRoute cannot register new ones.

Docker / Container deployment

Only route credentials are needed — WithHostIP and ADS_LOCAL_AMS are optional:

target, _ := ads.NewAMSAddress(targetAMS, 851)
sess, _ := ads.NewSession(ctx, ads.AMSEndpoint{IP: plcIP, Port: 48898, AMS: target},
    ads.WithRoute("my-route", "Administrator", "password"),
)
sess.Connect(ctx)

The PLC stores the UDP source IP (post-NAT) for the route, not the computerName from the packet. Auto-derived NetID from the container IP works because ADS uses the existing TCP connection for all communication including notifications.

Note: Tested with TwinCAT 3 via Colima on macOS. More extensive testing across TwinCAT versions and container runtimes is ongoing. See PROTOCOL.md for details.

Integration tests

Integration tests require a real Beckhoff PLC on the network. They are gated behind a build tag and skipped by default.

Environment file format (.env.integration.XXX):

## Required
ADS_PLC_IP=192.168.0.1          # PLC IP address
ADS_TARGET_AMS=5.0.0.1.1.1      # PLC AMS NetID
ADS_TARGET_PORT=851              # AMS port (851 for TC3, 801 for TC2)

## Optional — auto-derived if omitted
ADS_LOCAL_AMS=192.168.0.100.1.1 # Local AMS NetID (default: auto from local IP)
ADS_HOST_IP=192.168.0.100       # IP the PLC uses to reach us

## Optional — for auto-creating AMS route
ADS_ROUTE_USER=Administrator     # PLC admin username
ADS_ROUTE_PASS=1                 # PLC admin password

## Optional — test-specific symbol names
ADS_WRITE_BOOL=GVL_WriteTest.bWriteBool
ADS_READ_COUNTER=GVL_ProcessData.nMasterCycleCounter

Running integration tests:

# Source env and run all integration tests against a PLC
set -a && source .env.integration && set +a
go test -tags integration -v -timeout 60s

# Run a specific test
go test -tags integration -run TestIntegrationConnect -v -timeout 30s

Symbol browse test (browse_test.go) — connects to a PLC, loads all symbols slowly (chunked reads with delays to avoid disrupting real-time tasks), and writes a .var file documenting every symbol:

set -a && source .env.integration && set +a
go test -tags integration -run TestBrowseAllSymbols -v -timeout 60s
# → writes plc_192_168_0_1.var (filename derived from ADS_PLC_IP)

The .var files list all symbols with name, datatype, size, index group, offset, and comment.

CI

CI runs automatically on pull requests to main with 4 parallel jobs: lint, test, test-race, and build. All must pass before merging.

TODO

  • Enum string resolution: Add an option to return enum constant names (e.g. "RUNNING") instead of numeric values (e.g. "2"). Requires parsing enum constant values from the datatype table's extra data. Only possible for TC3 non-strict enums; TC3 strict enums and TC2 do not expose constant names in the datatype table.
  • TCP-based route registration: Investigate whether AMS routes can be created via ADS system service commands over the existing TCP connection (AMS port 10000) instead of the UDP protocol (port 48899). This would eliminate the UDP 48899 firewall requirement and simplify deployment in locked-down networks. The Beckhoff TcAmsRemoteMgr service handles UDP route requests — a TCP equivalent may exist via the ADS system service but is unconfirmed.
  • In-process AMS router subpackage (router/): Embeddable Go AMS router that lets a single process host multiple Sessions targeting the same PLC, and that doubles as a standalone daemon (cmd/ads-router/) front-end for multi-process deployments. Bypasses the TwinCAT 1-TCP-per-source-IP constraint (see §Limitations) without requiring Beckhoff's .NET Beckhoff.TwinCAT.Ads.TcpRouter. Lib internals already prepped: AMSHeader / ParseAMSHeader / EncodeAMSHeader wire codec exported, NotificationHandler callback type exported, WithSkipRouteRegistration option for router-fronted Sessions, WithLocalBindIP for multi-NIC host setups. Passthrough mode planned for v2.3.0 (no notification coalescing — each client gets its own PLC handle, matching Beckhoff's official TcpRouter behavior).

License

MIT — see LICENSE for details.

Original ADS protocol implementation based on go-native-ads by Bob Klosinski.

Documentation

Overview

Package ads is a pure-Go client for the Beckhoff TwinCAT ADS protocol.

Two layers, chosen at construction. Client (this file) is a thin RPC layer: one TCP connection, raw AMS framing, no cache or reconnect -- once it drops, build a new one. Session (session.go) wraps it with the symbol cache, persistent notifications, auto-reconnect and an FSM.

Session does NOT embed *Client: raw methods live on *Client, cache-aware ones on *Session. See docs/archive/specs/09-fsm-design.md.

Index

Constants

View Source
const AMSHeaderSize = 32

AMSHeaderSize is the on-wire size of an AMSHeader in bytes.

View Source
const LevelTrace = slog.Level(-8)

LevelTrace is a custom slog level for trace-level logging, below slog.LevelDebug (-4). Matches zerolog's Trace level semantics.

View Source
const MaxAMSPayloadSize = 32 * 1024 * 1024 // 32 MiB

MaxAMSPayloadSize is a sanity bound on the Length field of a parsed AMSHeader. The Beckhoff ADS protocol does not define a hard ceiling but real-world frames stay under ~16MB (SumRead with thousands of items is the typical upper bound). 32MB is a defensive cap that rejects corrupt or hostile frames before they cause downstream slice-bounds panics in the documented "b[AMSHeaderSize : AMSHeaderSize+h.Length]" pattern.

View Source
const ReturnCodeErrorOffset = 0x0700

ReturnCodeErrorOffset General ADS errors begin at this offset

Variables

View Source
var (
	// ErrBatchSymbolUnresolved: the symbol could not be resolved to an address,
	// so the item was never put on the wire.
	ErrBatchSymbolUnresolved = errors.New("symbol could not be resolved for batch")

	// ErrBatchValueUnserializable: the value could not be encoded for this
	// symbol's data type, so the write was never put on the wire. A type change
	// under an online change produces this.
	ErrBatchValueUnserializable = errors.New("value could not be serialized for batch write")

	// ErrBatchValueUnparsable: the PLC answered but the bytes did not decode
	// against the cached type — the shape an undetected online change takes.
	ErrBatchValueUnparsable = errors.New("batch value could not be parsed")

	// ErrBatchSymbolVanished: the cache entry disappeared mid-roundtrip
	// (loadSymbols swapped the cache), so the PLC's answer has nowhere to land.
	ErrBatchSymbolVanished = errors.New("symbol removed from cache during batch")

	// ErrBatchNoResult: the PLC's response carried fewer items than the request,
	// so this item has no verdict at all.
	ErrBatchNoResult = errors.New("no result returned for symbol")
)

Reasons the library itself, rather than the PLC, produced no value for an item of a batch read or write. They appear in BatchItemError.Skipped, wrapped around the underlying cause where there is one, so both errors.Is(err, ErrBatchSymbolUnresolved) and errors.Is(err, theUnderlyingCause) hold.

View Source
var (
	// ErrNotificationDuplicate — the symbol already has an active notification.
	ErrNotificationDuplicate = errors.New("symbol already subscribed")
	// ErrNotificationChannelMismatch — all notifications on one connection must
	// share a single updateReceiver channel.
	ErrNotificationChannelMismatch = errors.New("all notifications on a connection must use the same updateReceiver channel")
	// ErrNotificationSymbolVanished — the symbol left the cache between resolve
	// and commit (online change, or a concurrent LoadSymbols).
	ErrNotificationSymbolVanished = errors.New("symbol removed from cache during subscribe")
	// ErrNotificationStrandedByReload — the symbol cache was reloaded while the
	// batch was in flight, so this entry's registration no longer exists.
	// Retryable: the caller (or resubscribeNotifications) should re-subscribe.
	ErrNotificationStrandedByReload = errors.New("symbol cache reloaded during batch subscribe")
	// ErrNotificationTransportFailure — the batch could not be sent.
	ErrNotificationTransportFailure = errors.New("batch transport failure")
)

Reasons a batch subscribe refused to commit an entry, reported via SumNotificationResult.Skipped. They are sentinels because the right response differs per reason and callers should branch with errors.Is rather than on message text: a stranded or transport-failed entry is worth retrying, a duplicate or channel mismatch is a caller bug that retrying cannot fix.

Each is wrapped with the symbol name, so errors.Is matches while the message still says which symbol.

View Source
var ErrDisconnected = errors.New("connection is disconnected")

ErrDisconnected indicates the underlying TCP connection is not available — either Close() has been called or a reconnect has failed. Callers should use errors.Is(err, ErrDisconnected) to detect this case.

View Source
var ErrEstablishedDropped = errors.New("established connection dropped by the PLC or the network")

ErrEstablishedDropped reports a connection that had been carrying AMS frames and was then dropped by the PLC or something on the path. The route demonstrably existed, so route tables are the wrong place to look; eviction by another client on this host IP, a runtime restart, or the network path are the candidates.

View Source
var ErrRouteNotServed = errors.New("connection dropped before the PLC served any frame")

ErrRouteNotServed reports a connection that was dropped without ever carrying an AMS frame: the addressing or the route is the thing to look at. Distinct from ErrEstablishedDropped so a consumer can branch without matching strings.

View Source
var ErrRouterUnresponsive = errors.New("the PLC's AMS router answered nothing; it is briefly unreachable, not misconfigured")

ErrRouterUnresponsive reports a PLC whose AMS router answered nothing, on TCP or UDP 48899, for long enough that the route could not be checked. Nothing to fix: retry. Measured at ~8s after a client restart on a TC3 4024.

View Source
var ErrRuntimeNotRunning = errors.New("ads: PLC runtime is not in RUN")

ErrRuntimeNotRunning is returned by symbol and subscription calls when the system service reports the runtime is not in RUN. A refusal, not a retry: in CONFIG the runtime port does not exist, so the call cannot succeed and attempting it only yields a misleading AMS "port not found". The session keeps polling.

View Source
var ErrTransportClosed = errors.New("ads: client transport closed")

ErrTransportClosed is returned by every Client method after the underlying TCP transport has been closed (Close called, drop detected, dial failed). Callers reconstruct a new *Client to re-establish.

Functions

func AddRemoteRoute

func AddRemoteRoute(remoteHost string, localNetID [6]byte, routeName string, computerName string, username string, password string) error

AddRemoteRoute registers a route on the PLC over UDP 48899, telling it how to reach this client's NetID. remoteHost may carry the router's UDP port ("10.0.0.5:6499") when the PLC is behind NAT; computerName is the address the PLC should dial back.

Security: Beckhoff's protocol sends credentials in cleartext and offers no encrypted alternative. Trusted networks only.

func AddRemoteRouteWithLogger

func AddRemoteRouteWithLogger(logger *slog.Logger, remoteHost string, localNetID [6]byte, routeName string, computerName string, username string, password string) error

AddRemoteRouteWithLogger is like AddRemoteRoute but accepts an explicit logger.

func EncodeAMSHeader added in v2.2.0

func EncodeAMSHeader(h AMSHeader) []byte

EncodeAMSHeader serializes an AMSHeader to its 32-byte wire form. No length prefix is added; callers wrap with the 6-byte AMS-over-TCP header (1 unknown + 1 system + 4 length) when sending over TCP.

func ParseNetID added in v2.2.0

func ParseNetID(source string) (result [6]byte, err error)

ParseNetID converts a dotted-notation NetID string (e.g. "192.168.1.1.1.1") to a 6-byte array. Returns an error if the string is malformed (wrong number of parts or non-numeric values).

func ReadBit added in v2.1.0

func ReadBit(data []byte, bitIndex int) bool

ReadBit extracts a single bit from a byte slice. bitIndex 0 is the least significant bit of the first byte. Returns false if bitIndex is out of range.

func SetDefaultLogger

func SetDefaultLogger(logger *slog.Logger)

SetDefaultLogger sets the package-level logger used by standalone functions like AddRemoteRoute. Call this before creating any connections if you want package-level functions to use a custom logger.

func WriteBit added in v2.1.0

func WriteBit(data []byte, bitIndex int, value bool)

WriteBit sets or clears a single bit in a byte slice. bitIndex 0 is the least significant bit of the first byte. Does nothing if bitIndex is out of range.

Types

type ADSDataType added in v2.2.0

type ADSDataType uint32

ADST_ data type IDs from the ADS protocol (ADSDATATYPEID enum). The PLC sends these numeric codes in symbolEntry.DataType to identify the base type of a variable. Works on both TwinCAT 2 and TwinCAT 3. Source: Beckhoff TC2_Utilities ADSDATATYPEID ADSDataType identifies a primitive IEC 61131-3 data type by the numeric code Beckhoff publishes in symbolEntry.DataType. Works on both TwinCAT 2 and TwinCAT 3. The companion ADSTBigType code is the catch-all for composite types (structs, enums, type aliases, arrays).

const (
	ADSTVoid    ADSDataType = 0
	ADSTInt16   ADSDataType = 2  // INT
	ADSTInt32   ADSDataType = 3  // DINT
	ADSTReal32  ADSDataType = 4  // REAL
	ADSTReal64  ADSDataType = 5  // LREAL
	ADSTInt8    ADSDataType = 16 // SINT
	ADSTUint8   ADSDataType = 17 // USINT/BYTE
	ADSTUint16  ADSDataType = 18 // UINT/WORD
	ADSTUint32  ADSDataType = 19 // UDINT/DWORD
	ADSTInt64   ADSDataType = 20 // LINT
	ADSTUint64  ADSDataType = 21 // ULINT/LWORD
	ADSTString  ADSDataType = 30 // STRING
	ADSTWString ADSDataType = 31 // WSTRING
	ADSTBool    ADSDataType = 33 // BOOL
	// ADSTBigType is the PLC's catch-all for composite/user-defined types:
	// structs, enums, type aliases, arrays. PLC sends this when the leaf does
	// not have a primitive ADST_ code (e.g. TC2 always reports enums this way;
	// TC3 sometimes reports the underlying primitive but falls back to BIGTYPE
	// for opaque types). Structs are caught earlier by the parse path's
	// Children branch, so a symbol reaching inferBaseType with BIGTYPE + a
	// 1/2/4/8 byte size is enum-like — safe to interpret as signed integer.
	ADSTBigType ADSDataType = 65
)

type ADSState added in v2.1.0

type ADSState uint16

ADSState is the run-time state of an ADS device (typically the PLC runtime), as reported by Client.ReadState.

const (
	ADSStateInvalid      ADSState = 0
	ADSStateIdle         ADSState = 1
	ADSStateReset        ADSState = 2
	ADSStateInit         ADSState = 3
	ADSStateStart        ADSState = 4
	ADSStateRun          ADSState = 5
	ADSStateStop         ADSState = 6
	ADSStateSaveCfg      ADSState = 7
	ADSStateLoadCfg      ADSState = 8
	ADSStatePowerFailure ADSState = 9
	ADSStatePowerGood    ADSState = 10
	ADSStateError        ADSState = 11
	ADSStateShutdown     ADSState = 12
	ADSStateSuspend      ADSState = 13
	ADSStateResume       ADSState = 14
	ADSStateConfig       ADSState = 15 // System Is In Config Mode
	ADSStateReconfig     ADSState = 16 // System Should Restart In Config Mode
	ADSStateMaxStates    ADSState = 255
)

type AMSAddress added in v2.1.0

type AMSAddress struct {
	NetID [6]byte
	Port  uint16
}

AMSAddress identifies an ADS endpoint by 6-byte NetID and 16-bit port.

Wire layout is those 8 bytes contiguously, which encoding/binary reproduces from declaration order. DO NOT reorder or insert fields, or change either type, without updating AMSHeader and every binary.Read/Write site.

func NewAMSAddress added in v2.2.0

func NewAMSAddress(netID string, port uint16) (AMSAddress, error)

NewAMSAddress parses a dotted-notation NetID string plus a uint16 port into an AMSAddress. The typical TwinCAT 3 PLC runtime listens on port 851 (PortR0PlcTc3).

func (AMSAddress) Equal added in v2.2.0

func (a AMSAddress) Equal(other AMSAddress) bool

Equal reports whether a and other have the same NetID and Port.

func (AMSAddress) NetIDString added in v2.2.0

func (a AMSAddress) NetIDString() string

NetIDString returns the AMS NetID in dotted notation (e.g. "192.168.1.1.1.1"). Use String for the full "NetID:Port" form.

func (AMSAddress) String added in v2.2.0

func (a AMSAddress) String() string

String returns "NetID:Port" in dotted notation, e.g. "1.2.3.4.1.1:851".

type AMSEndpoint added in v2.2.0

type AMSEndpoint struct {
	// IP is the host or address of the PLC (or of the NAT that forwards to it).
	IP string
	// Port is the TCP port carrying AMS. Defaults to 48898, TwinCAT's own.
	Port int
	// AMS is the target AMS address. A zero NetID and/or Port is resolved from
	// the device — see NewSession.
	AMS AMSAddress
	// RouterPort is the AMS router's UDP port (default 48899), used to register a
	// route and identify the device. Set it behind NAT, where the forwarded UDP
	// port differs from the TCP one and cannot be derived from Port. Only the two
	// UDP calls use it; notifications arrive on the TCP connection we opened.
	RouterPort int
}

AMSEndpoint identifies a remote ADS endpoint at the TCP and AMS layers. IP+Port locate the TwinCAT runtime over TCP (port 48898 by default); AMS is the target AMSAddress carried in every ADS request header.

type AMSError added in v2.3.0

type AMSError struct {
	Code ReturnCode
}

AMSError is a rejection from the AMS router, not a verdict about any ADS item: the request never reached a service that could answer. A system in CONFIG answers 0x06 for every request to a runtime port. Branch with errors.Is against the ReturnCode constants, or read Code off the typed value.

func (AMSError) Error added in v2.3.0

func (e AMSError) Error() string

func (AMSError) Is added in v2.3.0

func (e AMSError) Is(target error) bool

Is makes errors.Is(err, ReturnCodeX) match the router's code without making a router rejection indistinguishable from an ADS device verdict.

There is deliberately no Unwrap: errors.As traverses it, and every abort guard in this package asks errors.As(err, &ReturnCode) to mean "the device answered about my item". Unwrapping to Code would put the router back inside that answer — which is the bug this type exists to close, so do not add one.

type AMSHeader added in v2.2.0

type AMSHeader struct {
	Target    AMSAddress
	Source    AMSAddress
	Command   CommandID
	State     uint16
	Length    uint32
	ErrorCode uint32
	InvokeID  uint32
}

AMSHeader is the 32-byte ADS frame header (per Beckhoff AMS/ADS spec). Wire layout (little-endian):

offset 0  Target NetID(6) + Port(2)       = AMSAddress
offset 8  Source NetID(6) + Port(2)       = AMSAddress
offset 16 CommandID (2)
offset 18 State flags (2)
offset 20 Data length (4) — payload bytes following this header
offset 24 ErrorCode (4)
offset 28 InvokeID (4)

Exported so router and proxy implementations can parse, mutate and re-encode frames without touching the Client RPC plumbing.

func ParseAMSHeader added in v2.2.0

func ParseAMSHeader(b []byte) (AMSHeader, error)

ParseAMSHeader decodes a 32-byte AMS header from the front of b. The caller is responsible for stripping any preceding 6-byte AMS-over-TCP length prefix. Returns an error if b is shorter than AMSHeaderSize or if the decoded payload Length field exceeds MaxAMSPayloadSize (the latter catches corrupt/hostile frames that would otherwise panic the caller's "b[AMSHeaderSize : AMSHeaderSize+h.Length]" extraction). The payload (Length bytes) follows directly after the header in b; callers extract it as b[AMSHeaderSize : AMSHeaderSize+int(h.Length)].

type BackoffConfig added in v2.1.0

type BackoffConfig struct {
	InitialInterval time.Duration // delay for first N attempts (default: 1s)
	InitialAttempts int           // how many attempts at initial interval (default: 3)
	MidInterval     time.Duration // delay for mid-tier attempts (default: 5s)
	MidAttempts     int           // how many attempts at mid interval (default: 3)
	SlowInterval    time.Duration // delay for slow-tier attempts (default: 15s)
	SlowAttempts    int           // how many attempts at slow interval (default: 4)
	MaxInterval     time.Duration // cap after all tiers exhausted (default: 30s)
}

BackoffConfig controls reconnect timing behavior. Reconnection uses stepped intervals: fast retries first (for network blips), then progressively slower intervals to avoid overwhelming the PLC. Backoff resets on each successful reconnect.

func DefaultBackoffConfig added in v2.1.0

func DefaultBackoffConfig() BackoffConfig

DefaultBackoffConfig returns the default reconnect backoff configuration.

MaxInterval also caps the cross-cycle flap cooldown (see flapResetWindow), which is what governs a device that resets the connection on a timer. The 30s default protects the PLC's socket table at the cost of stream continuity during such an episode; lower it if the samples matter more than the sockets.

func (BackoffConfig) Validate added in v2.2.0

func (c BackoffConfig) Validate() error

Validate reports configuration problems that would produce pathological reconnect behavior: zero-or-negative intervals (zero-delay retry storms that exhaust ephemeral ports), negative attempt counts (skipped tiers surfacing as silent fast-fail), or a MaxInterval below the previous tier (caps that defeat the slow-tier ramp).

type BatchError added in v2.3.0

type BatchError struct {
	// Op is "read" or "write".
	Op string
	// Requested is how many symbols the caller asked for.
	Requested int
	// Succeeded is how many produced a valid result.
	Succeeded int
	// Items holds one entry per failed symbol, never empty.
	Items []BatchItemError
}

BatchError reports the items of a batch read or write that produced no value, while the successful ones stay in the call's map. It is the only error from these calls that leaves the map usable -- any other means the transport failed and no outcome is known. Recover it with errors.As. Batch size does not change the contract: one absent symbol alone or among forty reads the same.

func (*BatchError) Error added in v2.3.0

func (e *BatchError) Error() string

Error names the failed symbols and their reasons.

func (*BatchError) Unwrap added in v2.3.0

func (e *BatchError) Unwrap() []error

Unwrap exposes the library-side skip reasons so errors.Is can match the ErrBatch* sentinels through the BatchError. Per-item PLC codes are not unwrapped: a ReturnCode reached through errors.As would be indistinguishable from a call-level failure, which is the ambiguity this type exists to remove.

type BatchItemError added in v2.3.0

type BatchItemError struct {
	// Symbol is the symbol name as the caller passed it.
	Symbol string
	// Error is the PLC's per-item return code. Meaningful only when Skipped is nil.
	Error ReturnCode
	// Skipped is non-nil when the library produced no value for this item.
	Skipped error
}

BatchItemError is the per-item failure of a batch read or write, carrying the same three-state contract as SumNotificationResult so a device verdict is distinguishable from a library-side drop: Skipped != nil means the library never sent it or could not use the answer and Error is meaningless, otherwise Error is the PLC's own return code. Only failures appear.

func (BatchItemError) String added in v2.3.0

func (i BatchItemError) String() string

String renders one item as "MAIN.a (code 0x710)" or "MAIN.a (skipped: ...)".

type Client added in v2.1.0

type Client struct {
	// contains filtered or unexported fields
}

Client is the Beckhoff-equivalent thin RPC layer. One TCP connection, raw AMS framing, request multiplexing via InvokeID, listen + transmit + recv worker goroutines. No cache, no FSM, no reconnect, no callbacks.

Lifetime states are exactly two: alive (Dial succeeded, Close not yet called, transport not yet observed as dropped) and closed. Once closed, every public method returns ErrTransportClosed.

func Dial added in v2.1.0

func Dial(
	ip string,
	port int,
	target, source AMSAddress,
	requestTimeout time.Duration,
	opts ...ClientOption,
) (*Client, error)

Dial opens one TCP connection to ip:port, configures TCP keepalive, and spawns the listen / transmit / recvWorker goroutines. Returns a usable Client. See docs/archive/specs/09-fsm-design.md "Layer 2: Client (raw RPC)".

func (*Client) AcceptPeerConn added in v2.3.0

func (c *Client) AcceptPeerConn(conn net.Conn)

AcceptPeerConn adopts a connection the PLC opened TO US, reading its frames into the same response mux. Some devices treat a registered route as a peer router: they process our requests on our connection but answer over one they open back to us. Frames carry their own invokeID, so responses match regardless of which socket they arrive on.

func (*Client) AddDeviceNotification added in v2.1.0

func (c *Client) AddDeviceNotification(
	ctx context.Context,
	group uint32,
	offset uint32,
	length uint32,
	transmissionMode TransMode,
	maxDelay time.Duration,
	cycleTime time.Duration,
) (handle uint32, err error)

AddDeviceNotification registers a device notification with the PLC and returns the PLC-assigned handle. Raw RPC: no Session-side persistence. Callers wanting auto-resubscribe-on-reconnect use Session.AddSymbolNotification.

func (*Client) ClearProcessInputs added in v2.1.0

func (c *Client) ClearProcessInputs(ctx context.Context) error

EXPERIMENTAL: ClearProcessInputs writes all input process image bytes to zero.

func (*Client) ClearProcessOutputs added in v2.1.0

func (c *Client) ClearProcessOutputs(ctx context.Context) error

EXPERIMENTAL: ClearProcessOutputs writes all output process image bytes to zero.

func (*Client) Close added in v2.1.0

func (c *Client) Close() error

Close cancels worker goroutines, closes the TCP connection, and waits for workers to exit. Idempotent: subsequent calls are no-ops returning nil. Sets tx.disconnected so any subsequent RPC method returns ErrTransportClosed immediately.

func (*Client) DeleteDeviceNotification added in v2.1.0

func (c *Client) DeleteDeviceNotification(ctx context.Context, handle uint32) error

DeleteDeviceNotification deletes a device notification by handle. Raw RPC: returns the wire-level success/error. Callers that maintain activeNotifications must clean up themselves (Session does this in its wrapper Session.DeleteDeviceNotification below).

func (*Client) DownloadDataTypes added in v2.1.0

func (c *Client) DownloadDataTypes(ctx context.Context, length uint32) ([]byte, error)

DownloadDataTypes downloads the raw datatype-table bytes (group 0xF00E). Caller decodes via parseUploadSymbolInfoDataTypes. This is a raw RPC and bypasses the symbol cache; the caller is responsible for decoding the response.

func (*Client) DownloadInChunks added in v2.1.0

func (c *Client) DownloadInChunks(ctx context.Context, group uint32, totalLength uint32, chunkSize uint32, delay time.Duration) ([]byte, error)

downloadInChunks reads a large blob in chunkSize pieces via the Read command's offset, with an optional inter-chunk delay, so the loaders do not overwhelm the PLC's real-time loop. Returns immediately when chunking is already known to be unsupported, so the caller takes the single-request fallback.

func (*Client) DownloadSymbolList added in v2.1.0

func (c *Client) DownloadSymbolList(ctx context.Context, length uint32) ([]byte, error)

DownloadSymbolList downloads the raw symbol-table bytes (group 0xF00B). Caller decodes per parseUploadSymbolInfoSymbols. This is a raw RPC and bypasses the symbol cache; the caller is responsible for decoding the response.

func (*Client) GetHandleByName added in v2.1.0

func (c *Client) GetHandleByName(ctx context.Context, symbolName string) (uint32, error)

GetHandleByName resolves a symbol name to its PLC-side handle. Wire RPC; no cache. The handle is valid until the TCP transport drops or until the caller releases it via ReleaseHandle.

func (*Client) GetSymbolInfoByName added in v2.1.0

func (c *Client) GetSymbolInfoByName(ctx context.Context, symbolName string) (*symbol, error)

getSymbolInfoByName queries a single symbol's metadata from the PLC using GroupSymbolInfoByNameEx (0xF009). GetSymbolInfoByName resolves a single symbol on the PLC and returns a populated symbol with Group, Offset, Length, DataType, etc. Does NOT populate Children (struct / array children require full discovery via LoadSymbols / LoadSymbolList + LoadDataTypes). This is a raw RPC and bypasses the symbol cache; the caller is responsible for decoding the response.

func (*Client) GetSymbolUploadInfo added in v2.1.0

func (c *Client) GetSymbolUploadInfo(ctx context.Context) (SymbolUploadInfo, error)

GetSymbolUploadInfo reads the symbol-table size header from the PLC. Tries extended info (0xF00F, 24 bytes) first; falls back to basic info (0xF00C, 16 bytes) for older PLCs. Used by LoadSymbols / LoadSymbolList / LoadDataTypes to size the subsequent download. This is a raw RPC and bypasses the symbol cache; the caller is responsible for decoding the response.

func (*Client) GetSymbolVersion added in v2.1.0

func (c *Client) GetSymbolVersion(ctx context.Context) (uint8, error)

GetSymbolVersion reads the current PLC symbol version (single byte). Increments on online-change or download. This is a raw RPC and bypasses the symbol cache; the caller is responsible for decoding the response.

func (*Client) Read added in v2.1.0

func (c *Client) Read(ctx context.Context, group uint32, offset uint32, length uint32) (data []byte, err error)

Read issues ADS Read (cmd 2) against the given index group/offset.

func (*Client) ReadDeviceInfo added in v2.1.0

func (c *Client) ReadDeviceInfo(ctx context.Context) (response DeviceInfo, err error)

ReadDeviceInfo issues ADS ReadDeviceInfo (cmd 1).

func (*Client) ReadProcessInput added in v2.1.0

func (c *Client) ReadProcessInput(ctx context.Context, byteOffset, length uint32) ([]byte, error)

EXPERIMENTAL: ReadProcessInput reads bytes from the input process image at the given byte offset.

func (*Client) ReadProcessInputBit added in v2.1.0

func (c *Client) ReadProcessInputBit(ctx context.Context, byteOffset uint32, bitIndex uint8) (bool, error)

EXPERIMENTAL: ReadProcessInputBit reads a single bit from the input process image. bitIndex must be 0-7 (bit within a single byte).

func (*Client) ReadProcessInputSize added in v2.1.0

func (c *Client) ReadProcessInputSize(ctx context.Context) (uint32, error)

EXPERIMENTAL: ReadProcessInputSize returns the size of the input process image in bytes.

func (*Client) ReadProcessOutput added in v2.1.0

func (c *Client) ReadProcessOutput(ctx context.Context, byteOffset, length uint32) ([]byte, error)

EXPERIMENTAL: ReadProcessOutput reads bytes from the output process image at the given byte offset.

func (*Client) ReadState added in v2.1.0

func (c *Client) ReadState(ctx context.Context) (response States, err error)

ReadState issues ADS ReadState (cmd 4) and returns the PLC's ADS+device state.

func (*Client) ReadStateOnPort added in v2.3.0

func (c *Client) ReadStateOnPort(ctx context.Context, port Port) (States, error)

ReadStateOnPort reads the ADS state of another port on the same device. The system service (PortSystemService) is the one that matters: it answers while the system is in CONFIG, when the runtime ports do not exist at all.

func (*Client) ReleaseHandle added in v2.1.0

func (c *Client) ReleaseHandle(ctx context.Context, handle uint32) error

ReleaseHandle releases a symbol handle previously acquired via GetHandleByName. Wraps Write to GroupSymbolReleaseHandle so the Beckhoff-equivalent surface includes a symmetric release primitive.

func (*Client) SetNotificationHandler added in v2.1.0

func (c *Client) SetNotificationHandler(fn NotificationHandler)

SetNotificationHandler installs (or replaces) the callback for inbound DeviceNotification packets. nil disables dispatch (packets dropped after a Debug log entry). Concurrent-safe; the recvWorker reads under RLock.

func (*Client) SetOnDrop added in v2.1.0

func (c *Client) SetOnDrop(fn func())

SetOnDrop registers a callback fired on unexpected transport drop. Prefer WithOnDrop at construction time.

func (*Client) SumAddDeviceNotification added in v2.1.0

func (c *Client) SumAddDeviceNotification(ctx context.Context, requests []SumNotificationRequest) ([]SumNotificationResult, error)

SumAddDeviceNotification adds multiple device notifications in a single ADS round-trip using GroupSumupAddDeviceNotification (0xF085). Falls back to individual AddDeviceNotification calls on older PLCs.

func (*Client) SumDeleteDeviceNotification added in v2.1.0

func (c *Client) SumDeleteDeviceNotification(ctx context.Context, handles []uint32) ([]ReturnCode, error)

SumDeleteDeviceNotification deletes multiple device notifications by handle in a single ADS round-trip using GroupSumupDeleteDeviceNotification (0xF086). Falls back to individual DeleteDeviceNotification calls on older PLCs.

Returns the per-handle ReturnCode slice from the PLC. Persistent activeNotifications cleanup is the caller's responsibility; Session wraps this with its notifications.lock cleanup in Session.SumDeleteDeviceNotification.

func (*Client) SumRead added in v2.1.0

func (c *Client) SumRead(ctx context.Context, requests []SumReadRequest) ([]SumReadResult, error)

SumRead performs a batch read of multiple index group/offset/length combinations in a single ADS round-trip.

Tries SumReadEx2 (0xF084; TC3 and TC2 2.11), then SumReadEx (0xF083), then individual reads. Both sum variants share one response format, and the detected level is cached until reconnect. Deliberately not on executeSumCommand: the two-command-ID fallback does not fit its single-spec contract.

func (*Client) SumWrite added in v2.1.0

func (c *Client) SumWrite(ctx context.Context, requests []SumWriteRequest) ([]SumWriteResult, error)

SumWrite performs a batch write using GroupSumupWrite (0xF081). This writes multiple index group/offset combinations in a single ADS round-trip. If the sum command fails (e.g. on older PLCs), it falls back to individual writes.

SumWrite is intentionally NOT migrated to executeSumCommand. The request payload has a header section (Group+Offset+Length per item) followed by a concatenated data section (variable-size Data bytes per item). The generic helper's fixed itemWriteSize cannot represent this. Custom orchestration is required.

func (*Client) Write added in v2.1.0

func (c *Client) Write(ctx context.Context, group uint32, offset uint32, data []byte) error

Write issues ADS Write (cmd 3) to the PLC at the given index group/offset.

func (*Client) WriteProcessOutput added in v2.1.0

func (c *Client) WriteProcessOutput(ctx context.Context, byteOffset uint32, data []byte) error

EXPERIMENTAL: WriteProcessOutput writes bytes to the output process image at the given byte offset.

func (*Client) WriteProcessOutputBit added in v2.1.0

func (c *Client) WriteProcessOutputBit(ctx context.Context, byteOffset uint32, bitIndex uint8, value bool) error

EXPERIMENTAL: WriteProcessOutputBit writes a single bit to the output process image. bitIndex must be 0-7 (bit within a single byte).

func (*Client) WriteRead added in v2.1.0

func (c *Client) WriteRead(ctx context.Context, group uint32, offset uint32, readLength uint32, send []byte) (data []byte, err error)

WriteRead issues ADS ReadWrite (cmd 9): writes send and reads back up to readLength bytes in a single round-trip.

type ClientOption added in v2.1.0

type ClientOption func(*Client)

ClientOption configures optional construction parameters for Dial.

func WithClientLogger added in v2.1.0

func WithClientLogger(logger *slog.Logger) ClientOption

WithClientLogger sets the slog.Logger for a Client. Nil is ignored.

func WithClientRequestTimeout added in v2.1.0

func WithClientRequestTimeout(d time.Duration) ClientOption

WithClientRequestTimeout overrides the per-request and dial timeout. Values <= 0 are ignored (the default of 5s applies).

func WithNotificationHandler added in v2.1.0

func WithNotificationHandler(fn NotificationHandler) ClientOption

WithNotificationHandler installs a callback for inbound DeviceNotification packets. Session installs its own handler internally; raw Client consumers (CLI, web ADS browser) install their own to receive notifications, or leave nil to drop them silently.

func WithOnDrop added in v2.1.0

func WithOnDrop(fn func()) ClientOption

WithOnDrop registers a callback fired on unexpected transport drop. See SetOnDrop for the runtime equivalent.

type CommandID

type CommandID uint16

CommandID is the ADS command opcode carried in the AMS header (see Beckhoff ADS specification for the wire format).

const (
	CommandIDInValueID CommandID = iota
	CommandIDReadDeviceInfo
	CommandIDRead
	CommandIDWrite
	CommandIDReadState
	CommandIDWriteControl
	CommandIDAddDeviceNotification
	CommandIDDeleteDeviceNotification
	CommandIDDeviceNotification
	CommandIDReadWrite
)

type DeviceInfo

type DeviceInfo struct {
	Major      uint8
	Minor      uint8
	Version    uint16
	DeviceName [16]byte
}

DeviceInfo is the PLC's self-reported identity (returned by ReadDeviceInfo). DeviceName is a 16-byte null-padded ASCII field; trim at the first null byte for display.

type Group

type Group uint32

Group is an ADS index group identifier. The high-byte hex values address reserved system tables (symbol cache, process image, device data) per Beckhoff's published index-group reservations.

const (
	GroupSymbolTab   Group = 0xf000
	GroupSymbolName  Group = 0xf001
	GroupSymbolValue Group = 0xf002

	GroupSymbolHandleByName  Group = 0xF003
	GroupSymbolValueByName   Group = 0xF004
	GroupSymbolValueByHandle Group = 0xF005
	GroupSymbolReleaseHandle Group = 0xF006
	GroupSymbolInfoByName    Group = 0xF007
	GroupSymbolVersion       Group = 0xF008
	GroupSymbolInfoByNameEx  Group = 0xF009

	GroupSymbolDownload       Group = 0xF00A
	GroupSymbolUpload         Group = 0xF00B
	GroupSymbolUploadInfo     Group = 0xF00C
	GroupSymbolDownload2      Group = 0xF00D
	GroupSymbolDataTypeUpload Group = 0xF00E
	GroupSymbolUploadInfo2    Group = 0xF00F

	GroupSymbolNotification Group = 0xF010 // notification of named handle

	GroupSumupRead                     Group = 0xF080
	GroupSumupWrite                    Group = 0xF081
	GroupSumupReadWrite                Group = 0xF082
	GroupSumupReadEx                   Group = 0xF083
	GroupSumupReadEx2                  Group = 0xF084
	GroupSumupAddDeviceNotification    Group = 0xF085
	GroupSumupDeleteDeviceNotification Group = 0xF086

	GroupIoImageRwib   Group = 0xF020 // read/write input byte(s)
	GroupIoImageRwix   Group = 0xF021 // read/write input bit
	GroupIoImageRisize Group = 0xF025 // read input size (in byte)
	GroupIoImageRwob   Group = 0xF030 // read/write output byte(s)
	GroupIoImageRwox   Group = 0xF031 // read/write output bit
	GroupIoImageCleari Group = 0xF040 // write inputs to null
	GroupIoImageClearo Group = 0xF050 // write outputs to null
	GroupIoImageRwiob  Group = 0xF060 // read input and write output byte(s)

	GroupDeviceData Group = 0xF100 // state, name, etc...
)

type HeartbeatRecovery added in v2.3.1

type HeartbeatRecovery int

Heartbeat: proving the caller's subscriptions are alive without asking the PLC. A runtime restart kills subscriptions while leaving the connection, symbol version and ADS state unchanged, so one cyclic subscription of our own turns silence into proof. See notificationManager.heartbeatHandle.

HeartbeatRecovery selects what happens when it goes silent. Recovery is not free -- a delete and an add per handle, 82 requests on a 41-symbol session -- but Immediate stays the default as the behaviour that has been in the field.

const (
	// HeartbeatRecoveryImmediate re-subscribes as soon as the silence window is
	// exceeded. The default.
	HeartbeatRecoveryImmediate HeartbeatRecovery = iota + 1
	// HeartbeatRecoveryConfirm requires two consecutive silent windows before
	// re-subscribing, trading time-to-notice for not firing the burst at a device
	// that was merely late.
	HeartbeatRecoveryConfirm
	// HeartbeatRecoveryObserve never re-subscribes. The silence is reported (log
	// plus the WithOnSymbolVersionChanged callback) and left to the consumer, for
	// a caller that would rather rebuild the session itself than have handles
	// churned underneath it.
	HeartbeatRecoveryObserve
)

func (HeartbeatRecovery) String added in v2.3.1

func (h HeartbeatRecovery) String() string

type NotificationConfig

type NotificationConfig struct {
	SymbolName       string
	MaxDelay         time.Duration
	CycleTime        time.Duration
	TransmissionMode TransMode
}

NotificationConfig holds configuration for a symbol notification, used for batch add and reconnect re-subscribe. MaxDelay and CycleTime use time.Duration for consistency with SumNotificationRequest and the rest of the standard library.

type NotificationHandler added in v2.2.0

type NotificationHandler func(ctx context.Context, handle uint32, timestamp uint64, content []byte)

NotificationHandler is the callback the recvWorker invokes when it decodes a DeviceNotification packet. Session installs its handleNotification here; raw Client consumers (web ADS browser, CLI tools, AMS routers) install their own. ctx is the Client's internal worker context — observe Done() to abort long-running handler work on Close. handle, timestamp, content are the parsed notification fields (handle = PLC-assigned ID, timestamp = Windows FILETIME, content = raw payload bytes).

type NotificationSample

type NotificationSample struct {
	Handle uint32
	Size   uint32
}

NotificationSample is the per-handle prefix inside a stamp record: Handle identifies the subscription that produced the value; Size is the byte length of the value bytes that follow.

type NotificationStream

type NotificationStream struct {
	Length uint32
	Stamps uint32
}

NotificationStream is the outer header of an ADS DeviceNotification payload: Length is the total payload length in bytes, Stamps is the number of StampHeader records that follow.

type Offset

type Offset uint32

Offset is the index-offset half of an ADS group/offset address; the interpretation depends on the index group (symbol-relative byte offset, process-image bit/byte index, device-data field, ...).

const (
	OffsetDeviceDataADSState    Offset = 0x0000 // ads state of device
	OffsetDeviceDataDeviceState Offset = 0x0002 // device state
)

type Port

type Port uint32

Port is a well-known AMS port number for a TwinCAT service. PortR0PlcTc3 (851) is the typical PLC runtime port for TwinCAT 3.

const (
	PortLogger    Port = 100
	PortR0Rtime   Port = 200
	PortR0Trace   Port = (PortR0Rtime + 90)
	PortR0Io      Port = 300
	PortR0Sps     Port = 400
	PortR0Nc      Port = 500
	PortR0Isg     Port = 550
	PortR0Pcs     Port = 600
	PortR0Plc     Port = 801
	PortR0PlcRts1 Port = 801
	PortR0PlcRts2 Port = 811
	PortR0PlcRts3 Port = 821
	PortR0PlcRts4 Port = 831
	PortR0PlcTc3  Port = 851
	// PortSystemService is the TwinCAT system service. Unlike the runtime ports it
	// stays up while the system is in CONFIG — measured on TC3.1.4024: every request
	// to 851 came back with AMS ErrorCode 6 (target port not found), while 10000
	// answered ADSState=15 (CONFIG). So it is the only place to ask what state the
	// system is actually in.
	PortSystemService Port = 10000
)

type Reason added in v2.2.0

type Reason string

Reason is the enumerated cause of a stale-cache detection or one-shot Update.Stale notification. Values are STABLE — safe for switch/case comparison by callers. New reasons may be added in future versions; callers SHOULD have a default branch.

const (
	ReasonSymbolVersionInvalid Reason = "symbol-version-invalid"
	ReasonSymbolNotFound       Reason = "symbol-not-found"
	ReasonInvalidOffset        Reason = "invalid-offset"
	ReasonSymbolNotActive      Reason = "symbol-not-active"
	ReasonNotifyHandleInvalid  Reason = "notify-handle-invalid"
	ReasonInvalidSize          Reason = "invalid-size"
	ReasonReloadCapExhausted   Reason = "reload-cap-exhausted"
	ReasonReloadInProgress     Reason = "reload-in-progress"
	// ReasonHeartbeatSilent is delivered when the internal heartbeat has gone
	// silent and the session is NOT re-subscribing because the caller chose
	// WithHeartbeatRecovery(HeartbeatRecoveryObserve). It is the only signal that
	// mode produces, and it means this session's subscriptions are dead until the
	// consumer rebuilds it.
	ReasonHeartbeatSilent Reason = "heartbeat-silent"
)

type RemoteIdentity added in v2.3.0

type RemoteIdentity struct {
	// AMS is the router's own address. The port is the router's (10000), NOT a
	// PLC runtime port — see RuntimePort.
	AMS AMSAddress
	// HostName is the device's computer name, e.g. "CX-4285CB". Empty if the
	// device did not report one.
	HostName string
	// Major, Minor and Build are the reported TwinCAT version, e.g. 3, 1, 4024.
	// Zero if the device did not report a version.
	Major uint8
	Minor uint8
	Build uint16
	// Tags is every tag exactly as received, keyed by Beckhoff tag id,
	// including the ones this package does not interpret. Present so callers
	// can inspect platform details (tag 4 embeds "TC/RTOS" on that platform)
	// without this package pretending to decode a layout it has not verified.
	Tags map[uint16][]byte
}

RemoteIdentity is what an AMS router reports about ITSELF, from IdentifyRemote.

Read AMS carefully: it identifies the router answering at that IP, not the PLC behind it. On a CX the two are the same device; on an engineering PC or gateway the PLC you want is an entry in its route table. Nothing in the response distinguishes the cases, and NetIDs are not derived from the IP.

func IdentifyRemote added in v2.3.0

func IdentifyRemote(ctx context.Context, host string) (RemoteIdentity, error)

IdentifyRemote asks the device at host for its own NetID and system details. host may carry a port for NAT forwarding; bare hosts use the protocol's UDP port. Read-only, honouring ctx's deadline if sooner than the default 3s. The answer describes the ROUTER, which is the PLC only when they are one device.

func IdentifyRemoteWithLogger added in v2.3.0

func IdentifyRemoteWithLogger(ctx context.Context, logger *slog.Logger, host string) (RemoteIdentity, error)

IdentifyRemoteWithLogger is IdentifyRemote with an explicit logger.

func (RemoteIdentity) RuntimePort added in v2.3.0

func (id RemoteIdentity) RuntimePort() uint16

RuntimePort returns the conventional AMS port of the FIRST PLC runtime for the reported TwinCAT major version: PortR0PlcRts1 (801) on TwinCAT 2, PortR0PlcTc3 (851) otherwise.

This is a convention, not a discovered value — the identify service reports the router's own port and never a runtime port. A TwinCAT 2 project with several runtimes uses 811, 821, 831; a TwinCAT 3 one uses 852, 853, … and a caller targeting any of those must say so explicitly.

func (RemoteIdentity) Version added in v2.3.0

func (id RemoteIdentity) Version() string

Version renders the reported TwinCAT version, e.g. "3.1.4024".

type ReturnCode

type ReturnCode uint32

ReturnCode is an ADS return code reported by the PLC in command responses and per-item sum-command results. The Error method on ReturnCode satisfies the error interface so callers can use errors.As to extract the code from wrapped errors.

const (
	ReturnCodeNoErrors ReturnCode = 0x00

	// Global error codes (0x01 - 0x1E)
	ReturnCodeGlobalInternalError       ReturnCode = 0x01
	ReturnCodeGlobalNoRtime             ReturnCode = 0x02
	ReturnCodeGlobalAllocLockedMemError ReturnCode = 0x03
	ReturnCodeGlobalInsertMailboxError  ReturnCode = 0x04
	ReturnCodeGlobalWrongReceiveHmsg    ReturnCode = 0x05
	ReturnCodeGlobalTargetPortNotFound  ReturnCode = 0x06
	ReturnCodeGlobalTargetNotFound      ReturnCode = 0x07
	ReturnCodeGlobalUnknownCommandID    ReturnCode = 0x08
	ReturnCodeGlobalBadTaskID           ReturnCode = 0x09
	ReturnCodeGlobalNoIO                ReturnCode = 0x0A
	ReturnCodeGlobalUnknownAdsCommand   ReturnCode = 0x0B
	ReturnCodeGlobalWin32Error          ReturnCode = 0x0C
	ReturnCodeGlobalPortNotConnected    ReturnCode = 0x0D
	ReturnCodeGlobalInvalidAdsLength    ReturnCode = 0x0E
	ReturnCodeGlobalInvalidAmsNetID     ReturnCode = 0x0F
	ReturnCodeGlobalLowInstallLevel     ReturnCode = 0x10
	ReturnCodeGlobalNoDebugAvailable    ReturnCode = 0x11
	ReturnCodeGlobalPortDisabled        ReturnCode = 0x12
	ReturnCodeGlobalPortAlreadyConn     ReturnCode = 0x13
	ReturnCodeGlobalAdsSyncW32Error     ReturnCode = 0x14
	ReturnCodeGlobalAdsSyncTimeout      ReturnCode = 0x15
	ReturnCodeGlobalAdsSyncAmsError     ReturnCode = 0x16
	ReturnCodeGlobalAdsSyncNoIndexMap   ReturnCode = 0x17
	ReturnCodeGlobalInvalidAdsPort      ReturnCode = 0x18
	ReturnCodeGlobalNoMemory            ReturnCode = 0x19
	ReturnCodeGlobalTcpSendError        ReturnCode = 0x1A
	ReturnCodeGlobalHostUnreachable     ReturnCode = 0x1B
	ReturnCodeGlobalInvalidAmsFragment  ReturnCode = 0x1C
	ReturnCodeGlobalTlsSendError        ReturnCode = 0x1D
	ReturnCodeGlobalAccessDenied        ReturnCode = 0x1E

	// Router error codes (0x500 - 0x50D)
	ReturnCodeRouterNoLockedMemory   ReturnCode = 0x500
	ReturnCodeRouterResizeMemory     ReturnCode = 0x501
	ReturnCodeRouterMailboxFull      ReturnCode = 0x502
	ReturnCodeRouterDebugBoxFull     ReturnCode = 0x503
	ReturnCodeRouterUnknownPortType  ReturnCode = 0x504
	ReturnCodeRouterNotInitialized   ReturnCode = 0x505
	ReturnCodeRouterPortAlreadyInUse ReturnCode = 0x506
	ReturnCodeRouterNotRegistered    ReturnCode = 0x507
	ReturnCodeRouterNoMoreQueues     ReturnCode = 0x508
	ReturnCodeRouterInvalidPort      ReturnCode = 0x509
	ReturnCodeRouterNotActivated     ReturnCode = 0x50A
	ReturnCodeRouterFragmentBoxFull  ReturnCode = 0x50B
	ReturnCodeRouterFragmentTimeout  ReturnCode = 0x50C
	ReturnCodeRouterToBeRemoved      ReturnCode = 0x50D

	// General ADS error codes (0x700 - 0x739)
	ReturnCodeDeviceError                 ReturnCode = (0x00 + ReturnCodeErrorOffset)
	ReturnCodeDeviceServiceNotSupported   ReturnCode = (0x01 + ReturnCodeErrorOffset)
	ReturnCodeDeviceInvalidGroup          ReturnCode = (0x02 + ReturnCodeErrorOffset)
	ReturnCodeDeviceInvalidOffset         ReturnCode = (0x03 + ReturnCodeErrorOffset)
	ReturnCodeDeviceInvalidAccess         ReturnCode = (0x04 + ReturnCodeErrorOffset)
	ReturnCodeDeviceInvalidSize           ReturnCode = (0x05 + ReturnCodeErrorOffset)
	ReturnCodeDeviceInvalidData           ReturnCode = (0x06 + ReturnCodeErrorOffset)
	ReturnCodeDeviceNotReady              ReturnCode = (0x07 + ReturnCodeErrorOffset)
	ReturnCodeDeviceBusy                  ReturnCode = (0x08 + ReturnCodeErrorOffset)
	ReturnCodeDeviceInvalidContext        ReturnCode = (0x09 + ReturnCodeErrorOffset)
	ReturnCodeDeviceNoMemory              ReturnCode = (0x0A + ReturnCodeErrorOffset)
	ReturnCodeDeviceInvalidParam          ReturnCode = (0x0B + ReturnCodeErrorOffset)
	ReturnCodeDeviceNotFound              ReturnCode = (0x0C + ReturnCodeErrorOffset)
	ReturnCodeDeviceSyntax                ReturnCode = (0x0D + ReturnCodeErrorOffset)
	ReturnCodeDeviceIncompatible          ReturnCode = (0x0E + ReturnCodeErrorOffset)
	ReturnCodeDeviceExists                ReturnCode = (0x0F + ReturnCodeErrorOffset)
	ReturnCodeDeviceSymbolNoFound         ReturnCode = (0x10 + ReturnCodeErrorOffset)
	ReturnCodeDeviceSymbolVersionInvalid  ReturnCode = (0x11 + ReturnCodeErrorOffset)
	ReturnCodeDeviceInvalidState          ReturnCode = (0x12 + ReturnCodeErrorOffset)
	ReturnCodeDeviceTransModeNotSupported ReturnCode = (0x13 + ReturnCodeErrorOffset)
	ReturnCodeDeviceNotifyHandleInvalid   ReturnCode = (0x14 + ReturnCodeErrorOffset)
	ReturnCodeDeviceClientUnknown         ReturnCode = (0x15 + ReturnCodeErrorOffset)
	ReturnCodeDeviceNoMoreHandles         ReturnCode = (0x16 + ReturnCodeErrorOffset)
	ReturnCodeDeviceInvalidWatchSize      ReturnCode = (0x17 + ReturnCodeErrorOffset)
	ReturnCodeDeviceNotInitialized        ReturnCode = (0x18 + ReturnCodeErrorOffset)
	ReturnCodeDeviceTimeout               ReturnCode = (0x19 + ReturnCodeErrorOffset)
	ReturnCodeDeviceNoInterface           ReturnCode = (0x1A + ReturnCodeErrorOffset)
	ReturnCodeDeviceInvalidInterface      ReturnCode = (0x1B + ReturnCodeErrorOffset)
	ReturnCodeDeviceInvalidClsID          ReturnCode = (0x1C + ReturnCodeErrorOffset)
	ReturnCodeDeviceInvalidObjID          ReturnCode = (0x1D + ReturnCodeErrorOffset)
	ReturnCodeDevicePending               ReturnCode = (0x1E + ReturnCodeErrorOffset)
	ReturnCodeDeviceAborted               ReturnCode = (0x1F + ReturnCodeErrorOffset)
	ReturnCodeDeviceWarning               ReturnCode = (0x20 + ReturnCodeErrorOffset)
	ReturnCodeDeviceInvalidArrayIndex     ReturnCode = (0x21 + ReturnCodeErrorOffset)
	ReturnCodeDeviceSymbolNotActive       ReturnCode = (0x22 + ReturnCodeErrorOffset)
	ReturnCodeDeviceAccessDenied          ReturnCode = (0x23 + ReturnCodeErrorOffset)
	ReturnCodeDeviceLicenseNotFound       ReturnCode = (0x24 + ReturnCodeErrorOffset)
	ReturnCodeDeviceLicenseExpired        ReturnCode = (0x25 + ReturnCodeErrorOffset)
	ReturnCodeDeviceLicenseExceeded       ReturnCode = (0x26 + ReturnCodeErrorOffset)
	ReturnCodeDeviceLicenseInvalid        ReturnCode = (0x27 + ReturnCodeErrorOffset)
	ReturnCodeDeviceLicenseSystemID       ReturnCode = (0x28 + ReturnCodeErrorOffset)
	ReturnCodeDeviceLicenseNoTimeLimit    ReturnCode = (0x29 + ReturnCodeErrorOffset)
	ReturnCodeDeviceLicenseFutureIssue    ReturnCode = (0x2A + ReturnCodeErrorOffset)
	ReturnCodeDeviceLicenseTimeToLong     ReturnCode = (0x2B + ReturnCodeErrorOffset)
	ReturnCodeDeviceException             ReturnCode = (0x2C + ReturnCodeErrorOffset)
	ReturnCodeDeviceLicenseDuplicated     ReturnCode = (0x2D + ReturnCodeErrorOffset)
	ReturnCodeDeviceSignatureInvalid      ReturnCode = (0x2E + ReturnCodeErrorOffset)
	ReturnCodeDeviceCertificateInvalid    ReturnCode = (0x2F + ReturnCodeErrorOffset)
	ReturnCodeDeviceLicenseOemNotFound    ReturnCode = (0x30 + ReturnCodeErrorOffset)
	ReturnCodeDeviceLicenseRestricted     ReturnCode = (0x31 + ReturnCodeErrorOffset)
	ReturnCodeDeviceLicenseDemoDenied     ReturnCode = (0x32 + ReturnCodeErrorOffset)
	ReturnCodeDeviceInvalidFuncID         ReturnCode = (0x33 + ReturnCodeErrorOffset)
	ReturnCodeDeviceOutOfRange            ReturnCode = (0x34 + ReturnCodeErrorOffset)
	ReturnCodeDeviceInvalidAlignment      ReturnCode = (0x35 + ReturnCodeErrorOffset)
	ReturnCodeDeviceLicensePlatform       ReturnCode = (0x36 + ReturnCodeErrorOffset)
	ReturnCodeDeviceForwardPL             ReturnCode = (0x37 + ReturnCodeErrorOffset)
	ReturnCodeDeviceForwardDL             ReturnCode = (0x38 + ReturnCodeErrorOffset)
	ReturnCodeDeviceForwardRT             ReturnCode = (0x39 + ReturnCodeErrorOffset)

	// Client error codes (0x740 - 0x756)
	ReturnCodeClientError               ReturnCode = (0x40 + ReturnCodeErrorOffset)
	ReturnCodeClientInvalidParameter    ReturnCode = (0x41 + ReturnCodeErrorOffset)
	ReturnCodeClientListEmpty           ReturnCode = (0x42 + ReturnCodeErrorOffset)
	ReturnCodeClientVarUsed             ReturnCode = (0x43 + ReturnCodeErrorOffset)
	ReturnCodeClientDuplicateInvokeID   ReturnCode = (0x44 + ReturnCodeErrorOffset)
	ReturnCodeClientSyncTimeout         ReturnCode = (0x45 + ReturnCodeErrorOffset)
	ReturnCodeClientW32Error            ReturnCode = (0x46 + ReturnCodeErrorOffset)
	ReturnCodeClientTimeoutInvalid      ReturnCode = (0x47 + ReturnCodeErrorOffset)
	ReturnCodeClientPortNotOpen         ReturnCode = (0x48 + ReturnCodeErrorOffset)
	ReturnCodeClientNoAMSAddress        ReturnCode = (0x49 + ReturnCodeErrorOffset)
	ReturnCodeClientSyncInternal        ReturnCode = (0x50 + ReturnCodeErrorOffset)
	ReturnCodeClientAddHash             ReturnCode = (0x51 + ReturnCodeErrorOffset)
	ReturnCodeClientRemoveHash          ReturnCode = (0x52 + ReturnCodeErrorOffset)
	ReturnCodeClientNoMoreSymbols       ReturnCode = (0x53 + ReturnCodeErrorOffset)
	ReturnCodeClientSyncResponseInvalid ReturnCode = (0x54 + ReturnCodeErrorOffset)
	ReturnCodeClientSyncPortLocked      ReturnCode = (0x55 + ReturnCodeErrorOffset)
	ReturnCodeClientRequestCancelled    ReturnCode = (0x56 + ReturnCodeErrorOffset)

	// RTime error codes (0x1000 - 0x101A)
	ReturnCodeRTimeInternal            ReturnCode = 0x1000
	ReturnCodeRTimeBadTimerPeriods     ReturnCode = 0x1001
	ReturnCodeRTimeInvalidTaskPtr      ReturnCode = 0x1002
	ReturnCodeRTimeInvalidStackPtr     ReturnCode = 0x1003
	ReturnCodeRTimePrioExists          ReturnCode = 0x1004
	ReturnCodeRTimeNoMoreTcb           ReturnCode = 0x1005
	ReturnCodeRTimeNoMoreSemas         ReturnCode = 0x1006
	ReturnCodeRTimeNoMoreQueues        ReturnCode = 0x1007
	ReturnCodeRTimeExtIrqAlreadyDef    ReturnCode = 0x100D
	ReturnCodeRTimeExtIrqNotDef        ReturnCode = 0x100E
	ReturnCodeRTimeExtIrqInstallFailed ReturnCode = 0x100F
	ReturnCodeRTimeIrqlNotLessOrEqual  ReturnCode = 0x1010
	ReturnCodeRTimeVmxNotSupported     ReturnCode = 0x1017
	ReturnCodeRTimeVmxDisabled         ReturnCode = 0x1018
	ReturnCodeRTimeVmxControlsMissing  ReturnCode = 0x1019
	ReturnCodeRTimeVmxEnableFails      ReturnCode = 0x101A

	// TCP/Winsock error codes
	ReturnCodeWsaeTimedOut    ReturnCode = 0x274C
	ReturnCodeWsaeConnRefused ReturnCode = 0x274D
	ReturnCodeWsaeHostDown    ReturnCode = 0x2751
)

func (ReturnCode) Error

func (rc ReturnCode) Error() string

Error implements the error interface for ReturnCode, allowing it to be used directly as an error.

func (ReturnCode) String

func (rc ReturnCode) String() string

String returns a human-readable description of the ADS return code. For unknown codes, it returns the hex value.

type Session added in v2.1.0

type Session struct {
	// contains filtered or unexported fields
}

Session is the managed wrapper around one ADS *Client: symbol cache, persistent notifications, lifecycle FSM, auto-reconnect and route state. It does NOT embed *Client -- raw RPCs live on a *Client from Dial.

func NewSession added in v2.1.0

func NewSession(ctx context.Context, remote AMSEndpoint, opts ...SessionOption) (sess *Session, err error)

NewSession creates a session targeting remote. No I/O until Connect, except to resolve an incomplete remote.AMS over UDP -- the port then follows the TC major version, so several runtimes means setting it explicitly. Local NetID and AMS port default so sessions cannot collide on the PLC's handle table.

func (*Session) AddRoute added in v2.1.0

func (sess *Session) AddRoute(ctx context.Context, routeName, username, password string) error

AddRoute registers a route on the remote PLC using this connection's settings. It uses callbackIP (from WithHostIP) if set, otherwise derives the callback address from the source AMS NetID (first 4 bytes = IP).

func (*Session) AddSymbolNotification added in v2.1.0

func (sess *Session) AddSymbolNotification(ctx context.Context, symbolName string, maxDelay time.Duration, cycleTime time.Duration, transMode TransMode, updateReceiver chan *Update) (uint32, error)

AddSymbolNotification subscribes a single symbol. All notifications on one connection share the same channel, and a duplicate symbol is rejected; the stored channel is reused to re-subscribe after a reconnect. Prefer AddSymbolNotifications for more than one.

The caller MUST NOT close updateReceiver while any notification is active -- a recover guards against it, but samples are silently dropped. Delete them or Close first.

func (*Session) AddSymbolNotifications added in v2.1.0

func (sess *Session) AddSymbolNotifications(ctx context.Context, configs []NotificationConfig, ch chan *Update) ([]SumNotificationResult, error)

AddSymbolNotifications subscribes several symbols in one round-trip, returning results parallel to configs; a non-nil error means the batch never went at all. Partial outcomes are normal. Skipped != nil means the library did not commit it (match the ErrNotification* sentinels), otherwise Error is the PLC's verdict and NoErrors means Handle is valid. Do not close ch while notifications are active.

func (*Session) BrowseSymbols added in v2.1.0

func (sess *Session) BrowseSymbols(path string) ([]SymbolBrowseEntry, error)

BrowseSymbols returns browsable entries at the given path in the symbol hierarchy. If path is empty, returns root-level groupings (first path segments). If path is specified, returns children of that symbol or prefix. Requires LoadSymbolList() or LoadSymbols() to have been called first.

func (*Session) CheckSymbolVersion added in v2.1.0

func (sess *Session) CheckSymbolVersion(ctx context.Context) (changed bool, err error)

CheckSymbolVersion compares the current PLC symbol version against the stored version. Returns true if the version has changed.

func (*Session) Close added in v2.1.0

func (sess *Session) Close() error

Close releases PLC-side notifications and symbol handles, cancels the session context, closes the socket and waits for the workers. Idempotent: the teardown runs once and a repeat call only re-waits finished workers. Implements io.Closer.

func (*Session) Connect added in v2.1.0

func (sess *Session) Connect(ctx context.Context) (retErr error)

Connect dials the PLC and transitions to Connected. May bind a listening socket on the AMS port unasked, since some devices answer only on a connection they open to us. A failed Connect rolls back and stays usable for a retry. Not safe for concurrent use on one Session.

func (*Session) DeleteDeviceNotification added in v2.1.0

func (sess *Session) DeleteDeviceNotification(ctx context.Context, handle uint32) error

DeleteDeviceNotification on Session wraps the raw Client RPC with notifications.lock cleanup: removes the entry from activeNotifications, drops the cached notificationConfig, and clears notificationChannel when the last subscription dies. Callers that want raw delete behavior use the Client method directly.

func (*Session) GetSymbol added in v2.1.0

func (sess *Session) GetSymbol(ctx context.Context, symbolName string) (SymbolView, error)

GetSymbol returns a read-only SymbolView for the named symbol. Resolves on-demand if the symbol is not in the cache (single-symbol lookup against the PLC).

func (*Session) IsClosed added in v2.1.0

func (sess *Session) IsClosed() bool

IsClosed reports whether the session has reached the terminal Closed state. A closed session cannot be reused; construct a new one via NewSession. Distinct from IsDisconnected (transient transport loss).

func (*Session) IsDisconnected added in v2.1.0

func (sess *Session) IsDisconnected() bool

IsDisconnected returns whether the connection is currently in a disconnected state.

func (*Session) ListSymbols added in v2.1.0

func (sess *Session) ListSymbols() (map[string]SymbolView, error)

ListSymbols returns the full symbol table. Requires LoadSymbols() or LoadSymbolsSlow() to have been called first. Returns an error if full discovery has not been performed.

ListSymbols returns read-only SymbolViews for every symbol discovered via LoadSymbols/LoadSymbolsSlow. Keys are PLC-cased FullNames.

func (*Session) LoadDataTypes added in v2.1.0

func (sess *Session) LoadDataTypes(ctx context.Context, cfg SlowDiscoveryConfig) error

LoadDataTypes downloads only the datatype table (0xF00E) from the PLC in chunks. After calling this along with LoadSymbolList(), struct/array children can be browsed and expanded via BrowseSymbols().

func (*Session) LoadSymbolList added in v2.1.0

func (sess *Session) LoadSymbolList(ctx context.Context, cfg SlowDiscoveryConfig) error

LoadSymbolList downloads only the symbol table (0xF00B) from the PLC in chunks. This is the smaller of the two tables and enables browsing top-level symbol names. After calling this, BrowseSymbols() can list root symbols and navigate by prefix. To also expand struct/array children, call LoadDataTypes() afterwards.

func (*Session) LoadSymbols added in v2.1.0

func (sess *Session) LoadSymbols(ctx context.Context) error

LoadSymbols performs full symbol and datatype discovery from the PLC. After calling this, ListSymbols() returns all symbols, and struct/array children are available. Write operations with type aliases also work. This downloads the entire symbol and datatype tables in single requests, which may cause real-time jitter on the PLC. For large programs, consider LoadSymbolsSlow() instead.

func (*Session) LoadSymbolsSlow added in v2.1.0

func (sess *Session) LoadSymbolsSlow(ctx context.Context, cfg SlowDiscoveryConfig) error

LoadSymbolsSlow downloads the full symbol table in chunks with delays between each chunk, to minimize disruption to the PLC's real-time task. If the PLC does not support offset-based chunked reads, it falls back to downloading each table in a single request with a delay between them.

func (*Session) ReadFromSymbol added in v2.1.0

func (sess *Session) ReadFromSymbol(ctx context.Context, symbolName string) (string, error)

ReadFromSymbol reads a PLC symbol by name and returns its stringified value (handle resolved on-demand and cached).

func (*Session) ReadMultipleSymbols added in v2.1.0

func (sess *Session) ReadMultipleSymbols(ctx context.Context, names []string) (map[string]string, error)

ReadMultipleSymbols reads several symbols in one round-trip, returning a map of name to parsed value for those that succeeded. Failures are named in a *BatchError (errors.As), and the map stays usable -- one absent symbol in forty leaves thirty-nine present. Any other error means the transport failed and no outcome is known. Reading none returns nil, nil.

func (*Session) Reconnect added in v2.1.0

func (sess *Session) Reconnect(ctx context.Context) error

Reconnect re-establishes the transport, reloads symbols and re-subscribes. Cancelling ctx CLOSES the session, as exhausting the attempt limit does -- not a pause: Reconnecting has no exit to Disconnected, so a session left there would never retry and IsClosed() could not see it.

func (*Session) RefreshSymbols added in v2.1.0

func (sess *Session) RefreshSymbols(ctx context.Context) error

RefreshSymbols reloads the symbol table if the symbol version has changed. It releases old handles, reloads symbol/datatype tables, and re-acquires handles for active symbols.

func (*Session) RuntimeState added in v2.3.0

func (sess *Session) RuntimeState(ctx context.Context) (ADSState, error)

RuntimeState reads the device's ADS state from the system service port. This is the SYSTEM's state, not the runtime port's: ADSStateConfig means no runtime port is serving. Answers while the runtime is unavailable, which is the point.

func (*Session) SumDeleteDeviceNotification added in v2.1.0

func (sess *Session) SumDeleteDeviceNotification(ctx context.Context, handles []uint32) ([]ReturnCode, error)

SumDeleteDeviceNotification wraps the raw RPC with activeNotifications cleanup, returning the per-handle codes. On a partial result the codes processed so far are still flushed and both the partial slice and the error are returned, so in-memory state matches what the PLC saw rather than leaving phantom entries for the next reconnect to re-clean.

func (*Session) WriteMultipleSymbols added in v2.1.0

func (sess *Session) WriteMultipleSymbols(ctx context.Context, values map[string]string) (map[string]ReturnCode, error)

WriteMultipleSymbols writes several symbols in one round-trip, returning a map of name to per-symbol code for those that reached the PLC.

Do not read success from the map alone: NoErrors is 0, so a symbol that was never written is indistinguishable from one that succeeded. Failures are named in a *BatchError (errors.As), which says whether the PLC rejected it or the library never sent it; any other error means the transport failed and no outcome is known. Writing none returns nil, nil.

func (*Session) WriteToSymbol added in v2.1.0

func (sess *Session) WriteToSymbol(ctx context.Context, symbolName string, value string) error

WriteToSymbol writes a value to a PLC symbol by name (handle resolved on-demand and cached).

type SessionOption added in v2.1.0

type SessionOption func(*Session)

SessionOption configures optional parameters for NewSession.

func WithAmsPeerListen added in v2.3.0

func WithAmsPeerListen(port int) SessionOption

WithAmsPeerListen makes the session listen for a connection the PLC opens back to us and use it for responses. Needed for devices that treat a registered route as a peer router: they process our requests on our connection but answer over one they open to us. Without it such a device looks like it times out on everything, because the responses reach a socket nobody is listening on.

port is normally 48898, parameterised for tests and hosts already running a TwinCAT router. Off by default, since binding is not a client library's business unless asked; binding failures surface from Connect rather than silently.

func WithAutoReconnect added in v2.1.0

func WithAutoReconnect(enabled bool) SessionOption

WithAutoReconnect controls whether the connection automatically reconnects when the TCP connection drops. Default is true. When disabled, triggerReconnect sets the transport-down flag but does not launch a reconnect goroutine. Pending and subsequent RPCs return ErrDisconnected. The caller must call Reconnect() manually to re-establish the connection.

func WithBackoff added in v2.1.0

func WithBackoff(cfg BackoffConfig) SessionOption

WithBackoff sets the reconnect backoff configuration. Invalid configs are rejected at option-application time: a Warn is logged and the default is kept. Callers wanting hard validation can call cfg.Validate() before passing.

func WithForceRouteRegistration added in v2.1.0

func WithForceRouteRegistration() SessionOption

WithForceRouteRegistration skips probing and registers on every Connect and Reconnect, for environments where routes are not persistent. The cost is one UDP registration per attempt on a flapping link, against a router this library has seen go mute under duplicate entries -- freshness traded for route-table safety. Without it a session registers once, plus one healing registration per cooldown.

func WithHeartbeatRecovery added in v2.3.1

func WithHeartbeatRecovery(mode HeartbeatRecovery) SessionOption

WithHeartbeatRecovery selects what happens when the heartbeat goes silent. The default, Immediate, re-subscribes at once -- a delete plus an add per handle, 82 requests on a 41-symbol session, against a device that may have merely stalled. Confirm waits for a second silent window, doubling the time to notice but halving needless churn. Observe never re-subscribes and reports it instead. An unrecognised value keeps the default: a typo must not turn recovery off.

func WithHostIP added in v2.1.0

func WithHostIP(ip string) SessionOption

WithHostIP sets the IP address the PLC should use to reach this client. Required in Docker/VPN/NAT scenarios where the local TCP socket address differs from the externally routable IP. When set, AddRoute uses this IP as the callback address instead of deriving it from the AMS NetID.

func WithLocalAMS added in v2.2.0

func WithLocalAMS(local AMSAddress) SessionOption

WithLocalAMS sets the local (source) AMSAddress in outgoing ADS headers. NetID defaults to the local TCP source IP, Port to a random dynamic-range value. The AMS port is a logical id in the header, not the TCP source or destination port.

HAZARD with route registration: TC3 keys its table by ADDRESS, and two entries for one address take the router out of service for every client until it is cleared by hand. Safe: a NetID matching the address, or WithSkipRouteRegistration. Avoid two sessions from one host under different ones.

func WithLocalBindIP added in v2.2.0

func WithLocalBindIP(ip string) SessionOption

WithLocalBindIP forces the outbound TCP source IP; unset lets the OS pick. Used on hosts with IP aliases so each Session looks like a separate host and gets its own TCP slot -- TwinCAT allows one per source IP regardless of NetID (Beckhoff #49/#72). The alias must exist before Connect. An invalid IP is rejected at option time with a Warn, leaving OS-default routing.

func WithLocalMode added in v2.2.0

func WithLocalMode() SessionOption

WithLocalMode targets the in-process TwinCAT runtime at 127.0.0.1, used when the application runs on the same machine as the PLC runtime. Sets the local-mode flag that Connect uses to short-circuit the route probe and force the loopback target NetID 127.0.0.1.1.1.

func WithLogger

func WithLogger(logger *slog.Logger) SessionOption

WithLogger sets the logger for the Session and the underlying Client. If not provided, slog.Default() is used.

func WithMaxReconnectAttempts added in v2.1.0

func WithMaxReconnectAttempts(n int) SessionOption

WithMaxReconnectAttempts limits total TCP reconnection attempts before giving up. Default is 0 (infinite retries). When the limit is reached, the reconnect goroutine returns an error and the connection stays in disconnected state.

func WithMaxSymbolVersionReloadAttempts added in v2.1.0

func WithMaxSymbolVersionReloadAttempts(n int) SessionOption

WithMaxSymbolVersionReloadAttempts caps reload attempts under SymbolVersionAutoReload within a sliding window. Default: 3. n<1 is rejected (logged Warn, default kept).

Note: unlike WithMaxReconnectAttempts (where n=0 means infinite), reload attempts are intentionally bounded — runaway reload loops would hammer the PLC under recurring online-change conditions.

func WithNotificationHeartbeat added in v2.3.0

func WithNotificationHeartbeat(interval time.Duration, missed int) SessionOption

WithNotificationHeartbeat tunes the internal cyclic notification that detects subscriptions dying silently. A subscription can stop delivering with nothing observable happening, and an on-change subscription may be silent legitimately, so only a cyclic beat's absence is conclusive.

interval is the cycle time, missed how many beats may be lost before re-subscribing. Defaults 2s and 5; missed < 2 is raised to 2. See WithNotificationSilenceTimeout, WithHeartbeatRecovery and WithRuntimeStateWatch.

func WithNotificationSilenceTimeout added in v2.3.1

func WithNotificationSilenceTimeout(d time.Duration) SessionOption

WithNotificationSilenceTimeout says how long subscriptions may be silent before the session calls them dead, in wall-clock time -- the same decision as WithNotificationHeartbeat's missed argument, in the unit an operator thinks in. missed is derived from it at construction (rounded up, floored at 2), and whichever of the two options is applied later wins.

func WithOnDisconnect added in v2.1.0

func WithOnDisconnect(fn func()) SessionOption

WithOnDisconnect registers a callback invoked when a disconnect is detected. The callback runs in a separate goroutine and must not block.

func WithOnReconnect added in v2.1.0

func WithOnReconnect(fn func()) SessionOption

WithOnReconnect registers a callback invoked after a successful reconnect. The callback runs in a separate goroutine and must not block.

func WithOnSymbolVersionChanged added in v2.1.0

func WithOnSymbolVersionChanged(fn func(reason Reason)) SessionOption

WithOnSymbolVersionChanged registers a callback fired once per stale-cache detection. The reason argument is one of the documented enumerated values on Update.Reason (symbol-version-invalid, invalid-size, ...). Callback runs in its own goroutine — do NOT block.

Under SymbolVersionIgnore strategy this callback is the only signal for symbol-removed events: the dead handle's user channel goes silent (no terminal Update). Surviving sibling handles still receive a one-shot Stale=true Update; only the removed symbol's channel is mute.

func WithRequestTimeout added in v2.1.0

func WithRequestTimeout(d time.Duration) SessionOption

WithRequestTimeout overrides the per-request timeout for ADS commands and initial-dial timeout. Defaults to the value passed as requestTimeout in NewSession (or 5s if that was zero). Useful for slow PLCs or networks where a single command may legitimately take longer than the default.

Note: this option is also used as the net.DialTimeout for initial Connect and reconnect dial. A single value covers both ADS request and TCP dial semantics — split if you need different deadlines.

func WithRoute added in v2.1.0

func WithRoute(routeName, username, password string) SessionOption

WithRoute registers an AMS route during Connect, over UDP 48899 once the source NetID is derived and before any ADS command. Connect and Reconnect probe first and register only if that fails; WithForceRouteRegistration always registers.

Security: Beckhoff's protocol sends credentials in cleartext and offers no encrypted alternative. Trusted networks only.

func WithRouteActivationTimeout added in v2.3.0

func WithRouteActivationTimeout(d time.Duration) SessionOption

WithRouteActivationTimeout caps how long Connect waits for a freshly registered route to actually be served: the router acks the registration before the entry is live, and until then requests are dropped with no reply. Default 10s covers every PLC observed. Values <= 0 are ignored, and a deadline on Connect's context also bounds the wait, so this is only needed to wait longer.

func WithRuntimeStateWatch added in v2.3.1

func WithRuntimeStateWatch(d time.Duration) SessionOption

WithRuntimeStateWatch sets how often the session polls the system service for the runtime state. That reading is what lets symbol and subscription calls refuse with "the runtime is not running" instead of failing obscurely, and lets a session starting in CONFIG come up and wait. Default 5s, independent of the heartbeat interval.

func WithSkipRouteRegistration added in v2.2.0

func WithSkipRouteRegistration() SessionOption

WithSkipRouteRegistration disables route registration, for routes managed externally -- pre-registered on the PLC, or owned by a local AmsRouterDaemon. Equivalent to omitting WithRoute but explicit, so a caller can keep WithRoute for documentation and override here. Bypasses probe and AddRoute, so no UDP at all.

func WithStrictReconnect added in v2.1.0

func WithStrictReconnect(maxAttempts int) SessionOption

WithStrictReconnect makes reconnection fail if any previously-resolved on-demand symbol is no longer available on the PLC (e.g., after an online change). By default, missing symbols are skipped gracefully during reconnect. maxAttempts controls how many reconnect attempts are allowed before giving up:

  • 0 = fail immediately on first missing symbol
  • N > 0 = retry up to N times, then return error (connection closes)

func WithSymbolVersionReloadWindow added in v2.1.0

func WithSymbolVersionReloadWindow(d time.Duration) SessionOption

WithSymbolVersionReloadWindow sets the sliding window for reload-attempt counting. Default: 60s. d<=0 is rejected (logged Warn, default kept).

func WithSymbolVersionStrategy added in v2.1.0

func WithSymbolVersionStrategy(s SymbolVersionStrategy) SessionOption

WithSymbolVersionStrategy selects the online-change handling strategy. Default: SymbolVersionAutoReload (zero-value, applies if option not set).

Values outside the SymbolVersionAutoReload/Close/Ignore enumeration are rejected at option-application time: a warning is logged and the strategy falls back to AutoReload. The strategy controls what handleStaleDetection does when a stale-cache return code (0x711, 0x705, 0x710, 0x704, 0x703, 0x702) is observed.

func WithTargetCheck added in v2.3.0

func WithTargetCheck(c TargetCheck) SessionOption

WithTargetCheck sets what happens when the target NetID disagrees with what the device reports. Default TargetCheckWarn. Runs in Connect for one UDP round-trip, and only for a caller-supplied target -- an incomplete one is resolved from the device, which is authoritative. A device that does not answer identify is never a mismatch in any mode: a firewalled UDP port says nothing about the address.

func WithoutAmsPeerFallback added in v2.3.0

func WithoutAmsPeerFallback() SessionOption

WithoutAmsPeerFallback disables the automatic peer-listener fallback, which otherwise binds the AMS port when a Connect proves the PLC answers nothing at all (see WithAmsPeerListen). The fallback only binds for a session that would be dead anyway. Use this where a TwinCAT router already owns the port, or where a client process may not listen.

func WithoutNotificationHeartbeat added in v2.3.0

func WithoutNotificationHeartbeat() SessionOption

WithoutNotificationHeartbeat disables the heartbeat described in WithNotificationHeartbeat.

The cost it saves: one notification handle in the PLC's table per session, and one small cyclic sample per interval. The cost it accepts: a runtime restart or CONFIG toggle leaves this session's subscriptions dead permanently, with no error and nothing in the session's state to show it — the consumer has to notice the absence of data and rebuild the session itself.

func WithoutRuntimeStateWatch added in v2.3.1

func WithoutRuntimeStateWatch() SessionOption

WithoutRuntimeStateWatch turns the runtime-state poll off, saving one small request per interval. In exchange the gates fall back to permitting, so a PLC in CONFIG fails the old obscure way instead of saying the runtime is not running, and a session starting in CONFIG will not notice the return to RUN. Connect still does one synchronous read.

type SessionState added in v2.1.0

type SessionState uint32

SessionState is the explicit FSM state for a Session. See docs/archive/specs/09-fsm-design.md for the full state diagram, event taxonomy, and transition table.

The FSM is the source of truth for closed and reconnecting concerns. The transport-down flag (disconnected on *transport) is a finer-grained signal than the FSM state — it flips false the instant dialAndStart reattaches a live socket, even while the FSM is still Reconnecting.

const (
	SessionStateConstructed SessionState = iota
	SessionStateConnecting
	SessionStateConnected
	SessionStateReloading
	SessionStateDisconnected
	SessionStateReconnecting
	SessionStateClosed
)

func (SessionState) String added in v2.1.0

func (s SessionState) String() string

type SlowDiscoveryConfig

type SlowDiscoveryConfig struct {
	// ChunkSize is the number of bytes to download per request.
	// Default: 4096 bytes.
	ChunkSize uint32

	// ChunkDelay is the delay between chunk requests, giving the PLC
	// time to handle its real-time tasks. Default: 100ms.
	ChunkDelay time.Duration
}

SlowDiscoveryConfig configures chunked symbol table download.

type StaleInfo added in v2.2.0

type StaleInfo struct {
	Reason Reason
}

StaleInfo describes why a one-shot Stale Update was delivered. Non-nil iff the corresponding Update's value MAY be from a pre-online-change cache state. Reason is one of the documented Reason* constants.

type StampHeader

type StampHeader struct {
	Timestamp uint64
	Samples   uint32
}

StampHeader prefixes the samples that share a single PLC sample timestamp. Timestamp is in Windows FILETIME ticks (100 ns since 1601-01-01 UTC). Samples is the count of NotificationSample records that follow.

type States

type States struct {
	ADSState    ADSState
	DeviceState uint16
}

States holds the ADS and device state returned by ReadState.

type SumNotificationRequest

type SumNotificationRequest struct {
	Group            uint32
	Offset           uint32
	Length           uint32
	TransmissionMode TransMode
	MaxDelay         time.Duration
	CycleTime        time.Duration
}

SumNotificationRequest represents a single notification add request within a batch.

type SumNotificationResult added in v2.1.0

type SumNotificationResult struct {
	Handle  uint32
	Error   ReturnCode // PLC-side return code; valid only when Skipped == nil
	Skipped error      // non-nil if library skipped this entry; Error/Handle not meaningful
}

SumNotificationResult is a per-item result from SumAddDeviceNotification. Either Skipped is non-nil (library refused to send this entry — duplicate, resolution failure, transport-aborted batch) or Skipped is nil and Error carries the PLC-side return code. Handle is valid only when Skipped == nil AND Error == ReturnCodeNoErrors.

type SumReadRequest

type SumReadRequest struct {
	Group  uint32
	Offset uint32
	Length uint32
}

SumReadRequest represents a single read request within a sum/batch read.

type SumReadResult

type SumReadResult struct {
	Error ReturnCode
	Data  []byte
}

SumReadResult represents the result of a single read within a sum/batch read.

type SumWriteRequest

type SumWriteRequest struct {
	Group  uint32
	Offset uint32
	Data   []byte
}

SumWriteRequest represents a single write request within a sum/batch write.

type SumWriteResult

type SumWriteResult struct {
	Error ReturnCode
}

SumWriteResult represents the result of a single write within a sum/batch write.

type SymbolBrowseEntry

type SymbolBrowseEntry struct {
	Name        string // short name (e.g., "motor")
	FullName    string // full path (e.g., "MAIN.motor")
	DataType    string // type name (e.g., "ST_Motor", "INT")
	Size        uint32
	HasChildren bool // true if struct/array (requires LoadDataTypes to expand)
	Comment     string
}

SymbolBrowseEntry represents a browsable symbol or child.

type SymbolFlag added in v2.1.0

type SymbolFlag uint32

SymbolFlag represents bits in the symbol flags field returned by INFOBYNAMEEX (0xF009) and the bulk symbol upload. These flags control how extended symbol info is parsed and which notification modes are available.

const (
	// SymbolFlagPersistent indicates the symbol value survives PLC restarts.
	SymbolFlagPersistent SymbolFlag = 0x0001
	// SymbolFlagBitValue indicates the symbol is a single bit within a byte.
	SymbolFlagBitValue SymbolFlag = 0x0002
	// SymbolFlagReferenceTo indicates the symbol is a reference/pointer.
	SymbolFlagReferenceTo SymbolFlag = 0x0004
	// SymbolFlagTypeGuid indicates a 16-byte type GUID follows after the name/type/comment strings.
	SymbolFlagTypeGuid SymbolFlag = 0x0008
	// SymbolFlagTComObj indicates the symbol is a TcCOM object.
	SymbolFlagTComObj SymbolFlag = 0x0010
	// SymbolFlagReadOnly indicates the symbol is read-only.
	SymbolFlagReadOnly SymbolFlag = 0x0020
	// SymbolFlagContextMask extracts the PLC task index from bits 8-11: non-zero
	// binds the variable to a task and enables the InContext modes, zero means they
	// are rejected (0x070B on TC3) or silently ignored (TC2). Only variables local
	// to a PROGRAM POU in a multi-task project are non-zero.
	SymbolFlagContextMask SymbolFlag = 0x0F00
	// SymbolFlagAttributes indicates attribute key-value pairs follow after the type GUID.
	SymbolFlagAttributes SymbolFlag = 0x1000
	// SymbolFlagExtendedFlags indicates additional extended flags are present.
	SymbolFlagExtendedFlags SymbolFlag = 0x8000
)

func (SymbolFlag) ContextMask added in v2.1.0

func (f SymbolFlag) ContextMask() uint8

ContextMask extracts the PLC task context index from symbol flags (bits 8-11). Returns 0 if the symbol is not bound to a specific task.

func (SymbolFlag) Has added in v2.1.0

func (f SymbolFlag) Has(flag SymbolFlag) bool

Has returns true if the flag set contains the given flag(s).

type SymbolUploadDataType

type SymbolUploadDataType struct {
	DatatypeEntry datatypeEntry
	Name          string
	DataType      string
	Comment       string
	Children      map[string]*SymbolUploadDataType
}

type SymbolUploadInfo

type SymbolUploadInfo struct {
	SymbolCount    uint32
	SymbolLength   uint32
	DataTypeCount  uint32
	DataTypeLength uint32
	ExtraCount     uint32
	ExtraLength    uint32
}

type SymbolVersionStrategy added in v2.1.0

type SymbolVersionStrategy uint8

SymbolVersionStrategy selects online-change handling behavior (R-SES-011).

const (
	// SymbolVersionAutoReload is the default. On detection of an online
	// change, the Session re-runs symbol discovery and resubscribes
	// notifications. Bounded by WithMaxSymbolVersionReloadAttempts within
	// a sliding window (default 3 attempts / 60s).
	SymbolVersionAutoReload SymbolVersionStrategy = iota

	// SymbolVersionClose terminates the Session on detection. The
	// OnDisconnect callback fires and Session.Close() is invoked. The
	// caller decides reconnect timing.
	SymbolVersionClose

	// SymbolVersionIgnore surfaces the PLC error verbatim and flags the next sample
	// on surviving handles Stale, once. Removed symbols go SILENT instead -- no
	// terminal Update for a dead handle, so use WithOnSymbolVersionChanged to see
	// removals.
	SymbolVersionIgnore
)

func (SymbolVersionStrategy) String added in v2.1.0

func (s SymbolVersionStrategy) String() string

String returns the human-readable name of the strategy.

type SymbolView added in v2.1.0

type SymbolView struct {
	Name        string
	FullName    string
	DataType    string
	Comment     string
	Handle      uint32
	Group       uint32
	Offset      uint32
	Length      uint32
	BaseType    ADSDataType // protocol ADST_ code; see adsTypeToString
	Flags       SymbolFlag
	ContextMask uint8 // PLC task context (bits 8-11 of Flags); 0 = no task binding
	Parsed      bool  // true if Value has been parsed at least once at snapshot time
	IsRoot      bool  // true if this symbol has no parent (top-level program/global var)
	Value       string
	// contains filtered or unexported fields
}

SymbolView is a read-only snapshot of a symbol's metadata and cached value, captured under cache.lock at creation. It does not track later updates -- call GetSymbol again or subscribe for fresh data.

The snapshot model buys lock-free field access and internal consistency (Parsed and Value captured together), at the cost of going stale after a concurrent reload. IsValid() is false for the zero value. Children() and ChildrenWalk() release the lock before calling the iterator, so any Session method is safe inside a walk.

func (SymbolView) BaseTypeName added in v2.1.0

func (v SymbolView) BaseTypeName() string

BaseTypeName returns the IEC 61131-3 primitive underlying this symbol, for consumers that need the storage layout without tracking user-defined names. Resolved from the protocol ADST_ code, else the datatype table, else inferred from size -- inference is limited to 1- and 2-byte widths, since 4 and 8 are REAL/LREAL ambiguous.

Returns "" when none resolve -- no symbol has "" legitimately, so callers branch on it -- and warns once per symbol naming the remedy. Never fetches the table itself: a getter with no ctx and no error has no business doing I/O.

func (SymbolView) Children added in v2.1.0

func (v SymbolView) Children() map[string]SymbolView

Children returns SymbolViews for struct/array members captured at view creation time. Returns nil for scalars and for symbols whose subtree has not been populated by full discovery. The map is freshly allocated per call; callers can mutate it without affecting library state.

Each child is a freshly-snapshotted SymbolView (lookups walk the cache under cache.lock once per call, then release). Caller code in a walk loop is free to call any Session method - no lock held.

func (SymbolView) ChildrenWalk added in v2.1.0

func (v SymbolView) ChildrenWalk(fn func(SymbolView) bool)

ChildrenWalk visits every symbol in the subtree rooted at this view in depth-first order. Snapshots the entire subtree under cache.lock once, releases the lock, then invokes fn for each entry. fn is free to call any Session method (Value field reads are lock-free, GetSymbol etc. take their own locks - no deadlock risk).

Walk terminates early if fn returns false.

func (SymbolView) GetJSON added in v2.2.0

func (v SymbolView) GetJSON() string

GetJSON serializes the cached value: nested JSON walked from the children subtree for composites, a single literal for primitives. Returns "" for a detached view or a symbol no longer in the cache. Takes cache.lock briefly; safe from any goroutine.

func (SymbolView) IsValid added in v2.1.0

func (v SymbolView) IsValid() bool

IsValid reports whether the view is backed by a live connection. Zero-value SymbolView returns false; views obtained from GetSymbol/ListSymbols return true (until the connection is closed).

type TargetCheck added in v2.3.0

type TargetCheck int

TargetCheck selects what NewSession does when the device at the target IP reports a different NetID than the caller supplied. Set with WithTargetCheck.

const (
	// TargetCheckWarn logs a warning and continues. Default, because a mismatch
	// is not proof of a mistake: pointed at a router, the NetID you want
	// legitimately belongs to a device behind it.
	TargetCheckWarn TargetCheck = iota + 1
	// TargetCheckError refuses to construct the session. Right for a deployment
	// that talks straight to its PLCs, where a mismatch is always a
	// misconfiguration and failing at startup beats a session that connects and
	// then answers nothing.
	TargetCheckError
	// TargetCheckOff skips the check, and with it the one UDP round-trip it
	// costs. Also the way to stay silent on a host that is deliberately
	// addressed through a router.
	TargetCheckOff
)

type TransMode

type TransMode uint32

TransMode is the transmission mode for an ADS device notification — how the PLC decides when to deliver a sample to the subscriber.

const (
	TransModeNoTransmission TransMode = 0
	TransModeClientCycle    TransMode = 1
	TransModeClientOnChange TransMode = 2
	TransModeServerCycle    TransMode = 3
	TransModeServerOnChange TransMode = 4
	// The "InContext" variants run the notification check inside the PLC task cycle
	// rather than a separate ADS thread, for more deterministic timing. They need a
	// non-zero ContextMask (the owning task), which only variables local to a
	// PROGRAM POU in a multi-task project have -- GVLs and single-task projects are
	// always 0, and TC3 then rejects with 0x070B while TC2 silently never fires.
	// The AddSymbolNotification paths fall back automatically when it is 0.
	TransModeServerCycle2    TransMode = 5 // CyclicInContext
	TransModeServerOnChange2 TransMode = 6 // OnChangeInContext
	TransModeClient1Request  TransMode = 10
)

func (TransMode) String

func (tm TransMode) String() string

String returns a human-readable name for the transmission mode.

type Update

type Update struct {
	Variable  string
	Value     string
	TimeStamp time.Time
	Stale     *StaleInfo
}

Update is delivered to the user channel for each PLC notification sample. Stale is non-nil only on the first sample following a stale-cache detection (R-NOT-017 one-shot); the field is nil for normal samples. Couples the "this sample may be stale" signal with the reason in a single check — callers do `if u.Stale != nil { /* handle stale */ }`.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL