README
¶
ads-go
Beckhoff TwinCAT ADS client library for Go (unofficial).
Connect to a Beckhoff TwinCAT automation system using the ADS protocol from a Go application.
Note: Documentation structure inspired by jisotalo/ads-client (used with permission).
Project Status
Active development. Core features are stable and tested.
Implemented:
- ✅ Connection management (connect, disconnect, port registration)
- ✅ Read/write operations with automatic type conversion
- ✅ Raw memory operations (ReadRaw, WriteRaw, ReadWriteRaw)
- ✅ Symbol and data type introspection
- ✅ PLC state control (config/run modes)
- ✅ Device information reading
- ✅ Full type support (primitives, structs, arrays, enums, strings)
- ✅ ADS notifications (subscriptions) with automatic change detection
- ✅ State monitoring with restart detection
- ✅ Connection lifecycle hooks (OnConnect, OnDisconnect, OnConnectionLost)
Roadmap:
- ⏳ Variable handle management
- ⏳ RPC method invocation
- ⏳ Batch operations (sum commands)
Features
- Supports TwinCAT 2 and 3
- Supports connecting to local TwinCAT 3 runtime
- Supports any ADS-enabled target system (local runtime, remote PLC, I/O devices)
- Multiple connections from same host
- Reading and writing any variable type
- Automatic conversion between PLC and Go types
- Symbol and data type introspection
- PLC state control (start, stop, config mode)
- Device information reading
- Raw memory operations for advanced use cases
- Automatic 32/64-bit variable support (XINT, ULINT, etc.)
- Automatic byte alignment support (all pack-modes)
- ADS notifications/subscriptions with configurable cycle times
- Automatic TwinCAT state monitoring and restart detection
- Connection lifecycle hooks for robust error handling
- Structured logging support (log/slog)
Table of Contents
- Support
- Installing
- Minimal Example (TLDR)
- Connection Setup
- Important
- Getting Started
- Common Issues and Questions
- Architecture
- Roadmap
- Testing
- Examples
- License
Support
- Issues & bugs: GitHub Issues
- Discussions & help: GitHub Discussions
Installing
go get github.com/jarmocluyse/ads-go@latest
Import in your code:
import "github.com/jarmocluyse/ads-go/pkg/ads"
Minimal Example (TLDR)
This connects to a local PLC runtime, reads a value, writes a value, reads it again and then disconnects. The value is a string located at GVL_Global.StringValue.
package main
import (
"fmt"
"log"
"github.com/jarmocluyse/ads-go/pkg/ads"
)
func main() {
// Create client
client := ads.NewClient(ads.ClientSettings{
TargetNetID: "localhost",
}, nil)
// Connect
if err := client.Connect(); err != nil {
log.Fatal(err)
}
defer client.Disconnect()
fmt.Println("Connected to PLC")
// Read a value
value, err := client.ReadValue(851, "GVL_Global.StringValue")
if err != nil {
log.Fatal(err)
}
fmt.Printf("Value read (before): %v\n", value)
// Write a value
err = client.WriteValue(851, "GVL_Global.StringValue", "New value from Go!")
if err != nil {
log.Fatal(err)
}
// Read again to verify
value, err = client.ReadValue(851, "GVL_Global.StringValue")
if err != nil {
log.Fatal(err)
}
fmt.Printf("Value read (after): %v\n", value)
fmt.Println("Done!")
}
Connection Setup
The ads-go client can be used with multiple system configurations.

Setup 1 - Connect from Windows
This is the most common scenario. The client is running on a Windows PC that has TwinCAT Router installed (such as development laptop, Beckhoff IPC/PC, Beckhoff PLC).
Requirements:
- Client has one of the following installed:
- TwinCAT XAE (development environment)
- TwinCAT XAR (runtime)
- TwinCAT ADS
- An ADS route is created between the client and the PLC using TwinCAT router
Client settings:
client := ads.NewClient(ads.ClientSettings{
TargetNetID: "192.168.1.120.1.1", // AmsNetId of the target PLC
}, nil)
Setup 2 - Connect from Linux/Windows with .NET Router
In this scenario, the client is running on Linux or Windows without TwinCAT Router. The .NET based router can be run separately on the same machine.
Requirements:
- Client has .NET runtime installed
- Client has AdsRouterConsoleApp or similar running
- An ADS route is created between the client and the PLC (see AdsRouterConsoleApp docs)
Client settings:
client := ads.NewClient(ads.ClientSettings{
TargetNetID: "192.168.1.120.1.1", // AmsNetId of the target PLC
}, nil)
Setup 3 - Connect from any system (direct)
In this scenario, the client is running on a machine that has no router running (no TwinCAT router and no 3rd party router). For example, Raspberry Pi without any additional installations.
In this setup, the client directly connects to the PLC and uses its TwinCAT router for communication. Only one simultaneous connection from the client is possible.
Requirements:
- Target system (PLC) firewall has TCP port 48898 open
- Windows Firewall might block, make sure Ethernet connection is handled as "private"
- Local AmsNetId and ADS port are set manually
- Used
LocalAmsNetIdis not already in use - Used
LocalAdsPortis not already in use
- Used
- An ADS route is configured to the PLC (see below)
Setting up the route:
- At the PLC, open
C:\TwinCAT\3.1\Target\StaticRoutes.xml - Copy paste the following under
<RemoteConnections>:
<Route>
<Name>GoClient</Name>
<Address>192.168.1.10</Address>
<NetId>192.168.1.10.1.1</NetId>
<Type>TCP_IP</Type>
<Flags>64</Flags>
</Route>
- Edit
Addressto IP address of the client (which runs the Go app), such as192.168.1.10 - Edit
NetIdto any unused AmsNetId address, such as192.168.1.10.1.1 - Restart the PLC
Client settings:
client := ads.NewClient(ads.ClientSettings{
TargetNetID: "192.168.1.120.1.1", // AmsNetId of the target PLC
RouterAddr: "192.168.1.120", // PLC IP address
RouterPort: 48898,
}, nil)
Setup 4 - Connect from local system
In this scenario, the PLC is running the Go app locally. For example, the development PC or Beckhoff PLC with a screen for HMI.
Requirements:
- AMS router TCP loopback enabled (see Enabling localhost support)
- Should be already enabled in TwinCAT versions >= 4024.5
Client settings:
client := ads.NewClient(ads.ClientSettings{
TargetNetID: "127.0.0.1.1.1", // or "localhost"
}, nil)
Setup 5 - Docker container
It's also possible to run the client in Docker containers, also with a separate router (Linux systems).
Contact me if you need help with Docker setup.
Important
Enabling localhost support on TwinCAT 3
If connecting to the local TwinCAT runtime (Go app and PLC on the same machine), the ADS router TCP loopback feature has to be enabled.
TwinCAT 4024.5 and newer already have this enabled as default.
- Open registry editor (
regedit) - Navigate to:
32-bit operating system:
HKEY_LOCAL_MACHINE\SOFTWARE\Beckhoff\TwinCAT3\System\
64-bit operating system:
HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Beckhoff\TwinCAT3\System\
- Create new DWORD registry entry named
EnableAmsTcpLoopbackwith value of1 - Restart the system

Now you can connect to localhost using TargetNetID address of 127.0.0.1.1.1 or localhost.
Structured variables
When writing structured variables, the object properties are handled case-insensitively. This is because TwinCAT is case-insensitive.
In practice, it means that the following objects are equal when passed to WriteValue():
// These are equivalent in TwinCAT
map[string]any{
"sometext": "hello",
"somereal": 3.14,
}
map[string]any{
"SOmeTEXT": "hello",
"SOMEreal": 3.14,
}
If there are multiple properties with the same name (case-insensitive), the behavior is undefined.
Differences when using with TwinCAT 2
ADS port for the first PLC runtime is 801 instead of 851:
client := ads.NewClient(ads.ClientSettings{
TargetNetID: "192.168.1.120.1.1",
}, nil)
All variable and data type names are in UPPERCASE:
This might cause problems if your app is used with both TC2 & TC3 systems.

Global variables are accessed with dot (.) prefix (without the GVL name):
// TwinCAT 3
client.ReadValue(851, "GVL_Test.ExampleSTRUCT")
// TwinCAT 2
client.ReadValue(801, ".EXAMPLESTRUCT")
ENUMs are always numeric values only (no name strings).
Empty structs and function blocks (without members) can't be read.
Getting Started
Documentation
Full API documentation is available at https://pkg.go.dev/github.com/jarmocluyse/ads-go/pkg/ads
Complete working examples can be found in cmd/main.go and example/ directory.
Available Methods
| Method | Description |
|---|---|
Connect() |
Establishes connection to target system |
Disconnect() |
Closes connection and cleans up resources |
ReadValue(port, path) |
Reads variable value by path with auto type conversion |
WriteValue(port, path, value) |
Writes variable value by path with auto type conversion |
ReadRaw(port, indexGroup, indexOffset, size) |
Reads raw bytes from memory |
WriteRaw(port, indexGroup, indexOffset, data) |
Writes raw bytes to memory |
ReadWriteRaw(port, indexGroup, indexOffset, readLength, writeData) |
Combined read-write operation |
GetSymbol(port, path) |
Retrieves symbol metadata (IndexGroup, IndexOffset, Size, Type) |
GetDataType(name, port) |
Retrieves complete data type definition |
BuildDataType(name, port) |
Recursively builds complex data type structures |
ReadDeviceInfo() |
Reads device name and version information |
ReadTcSystemState() |
Reads current TwinCAT system state |
ReadTcSystemExtendedState() |
Reads extended system state including restart index (TwinCAT 4022+) |
GetCurrentState() |
Returns cached current system state (updated by state monitoring) |
SetTcSystemToConfig() |
Sets TwinCAT system to CONFIG mode |
SetTcSystemToRun() |
Sets TwinCAT system to RUN mode |
WriteControl(adsState, deviceState, targetPort) |
Low-level state control |
SubscribeValue(port, path, callback, settings) |
Subscribe to variable value changes with automatic notifications |
Unsubscribe(subscription) |
Unsubscribe from a specific subscription |
UnsubscribeAll() |
Unsubscribe from all active subscriptions |
Creating a Client
Settings are passed via the ClientSettings struct. The following settings are mandatory:
TargetNetID- Target runtime AmsNetId (required)RouterAddr- ADS router address (optional, defaults to 127.0.0.1:48898)
client := ads.NewClient(ads.ClientSettings{
TargetNetID: "localhost",
}, nil)
With custom logger:
import "log/slog"
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
client := ads.NewClient(ads.ClientSettings{
TargetNetID: "localhost",
}, logger)
Connecting
It's good practice to start a connection at startup and keep it open until the app is closed.
package main
import (
"fmt"
"log"
"github.com/jarmocluyse/ads-go/pkg/ads"
)
func main() {
client := ads.NewClient(ads.ClientSettings{
TargetNetID: "localhost",
}, nil)
if err := client.Connect(); err != nil {
log.Fatal(err)
}
defer client.Disconnect()
fmt.Println("Connected to PLC")
// Your code here...
}
Reading Values
Reading Primitives
Use ReadValue() to read any PLC value. The method automatically resolves the symbol and converts the value to an appropriate Go type.
Reading INT:
value, err := client.ReadValue(851, "GVL_Read.StandardTypes.INT_")
if err != nil {
log.Fatal(err)
}
// Type assertion to get specific type
intValue := value.(int16)
fmt.Printf("INT value: %d\n", intValue)
// Output: 32767
Reading BOOL:
value, err := client.ReadValue(851, "GVL_Read.StandardTypes.BOOL_")
if err != nil {
log.Fatal(err)
}
boolValue := value.(bool)
fmt.Printf("BOOL value: %v\n", boolValue)
// Output: true
Reading REAL:
value, err := client.ReadValue(851, "GVL_Read.StandardTypes.REAL_")
if err != nil {
log.Fatal(err)
}
realValue := value.(float32)
fmt.Printf("REAL value: %.2f\n", realValue)
// Output: 3.14
Reading STRING:
value, err := client.ReadValue(851, "GVL_Read.StandardTypes.STRING_")
if err != nil {
log.Fatal(err)
}
stringValue := value.(string)
fmt.Printf("STRING value: %s\n", stringValue)
// Output: Hello from PLC
Reading Structs
Structs are returned as map[string]any:
value, err := client.ReadValue(851, "GVL_Read.ComplexTypes.STRUCT_")
if err != nil {
log.Fatal(err)
}
// Type assertion to map
structMap := value.(map[string]any)
// Access fields
boolField := structMap["BOOL_"].(bool)
intField := structMap["INT_"].(int16)
realField := structMap["REAL_"].(float32)
fmt.Printf("Struct fields: BOOL=%v, INT=%d, REAL=%.2f\n",
boolField, intField, realField)
// Or print entire struct
fmt.Printf("Entire struct: %+v\n", structMap)
/* Output:
map[BOOL_:true BOOL_2:false BYTE_:255 WORD_:65535 ...]
*/
Reading Arrays
Arrays are returned as []any:
value, err := client.ReadValue(851, "GVL_Read.StandardArrays.INT_5")
if err != nil {
log.Fatal(err)
}
// Type assertion to slice
arrayValue := value.([]any)
fmt.Printf("Array length: %d\n", len(arrayValue))
// Access individual elements
for i, item := range arrayValue {
intItem := item.(int16)
fmt.Printf("Array[%d] = %d\n", i, intItem)
}
/* Output:
Array[0] = 10
Array[1] = 20
Array[2] = 30
Array[3] = 40
Array[4] = 50
*/
Multidimensional arrays:
value, err := client.ReadValue(851, "GVL_Read.ComplexArrays.INT_2x3")
if err != nil {
log.Fatal(err)
}
// Outer array
outerArray := value.([]any)
for i, row := range outerArray {
// Inner array
innerArray := row.([]any)
fmt.Printf("Row %d: ", i)
for _, item := range innerArray {
fmt.Printf("%d ", item.(int16))
}
fmt.Println()
}
/* Output:
Row 0: 1 2 3
Row 1: 4 5 6
*/
Reading Enums
Enums are returned as map[string]any with "name" and "value" fields:
value, err := client.ReadValue(851, "GVL_Read.ComplexTypes.ENUM_")
if err != nil {
log.Fatal(err)
}
enumMap := value.(map[string]any)
enumName := enumMap["name"].(string)
enumValue := enumMap["value"].(int32)
fmt.Printf("Enum: %s = %d\n", enumName, enumValue)
// Output: Running = 100
Safe Type Assertions
Always use the comma-ok idiom for safe type assertions:
value, err := client.ReadValue(851, "GVL.SomeValue")
if err != nil {
log.Fatal(err)
}
// Safe type assertion
if intValue, ok := value.(int32); ok {
fmt.Printf("Integer value: %d\n", intValue)
} else {
fmt.Printf("Unexpected type: %T\n", value)
}
Writing Values
Writing Primitives
Use WriteValue() to write any PLC value.
Writing INT:
err := client.WriteValue(851, "GVL_Write.StandardTypes.INT_", 42)
if err != nil {
log.Fatal(err)
}
Writing BOOL:
err := client.WriteValue(851, "GVL_Write.StandardTypes.BOOL_", true)
if err != nil {
log.Fatal(err)
}
Writing REAL:
err := client.WriteValue(851, "GVL_Write.StandardTypes.REAL_", 3.14)
if err != nil {
log.Fatal(err)
}
Writing STRING:
err := client.WriteValue(851, "GVL_Write.StandardTypes.STRING_", "Hello from Go!")
if err != nil {
log.Fatal(err)
}
Writing Structs
Write structs using map[string]any:
structData := map[string]any{
"BOOL_": true,
"INT_": int16(100),
"REAL_": float32(2.71),
"STRING": "Test",
}
err := client.WriteValue(851, "GVL_Write.ComplexTypes.STRUCT_", structData)
if err != nil {
log.Fatal(err)
}
Note: Currently, partial struct updates require reading the existing value first, modifying it, then writing back:
// Read existing value
value, err := client.ReadValue(851, "GVL_Write.ComplexTypes.STRUCT_")
if err != nil {
log.Fatal(err)
}
// Modify specific field
structMap := value.(map[string]any)
structMap["INT_"] = int16(200)
// Write back
err = client.WriteValue(851, "GVL_Write.ComplexTypes.STRUCT_", structMap)
if err != nil {
log.Fatal(err)
}
Writing Arrays
Write arrays using slices:
// Using []int
intArray := []int{1, 2, 3, 4, 5}
err := client.WriteValue(851, "GVL_Write.StandardArrays.INT_5", intArray)
if err != nil {
log.Fatal(err)
}
// Or using []any
anyArray := []any{1, 2, 3, 4, 5}
err = client.WriteValue(851, "GVL_Write.StandardArrays.INT_5", anyArray)
if err != nil {
log.Fatal(err)
}
Multidimensional arrays:
// 2D array (2x3)
array2D := []any{
[]any{1, 2, 3},
[]any{4, 5, 6},
}
err := client.WriteValue(851, "GVL_Write.ComplexArrays.INT_2x3", array2D)
if err != nil {
log.Fatal(err)
}
Writing Enums
Write enums by name (string) or value (integer):
// By name
err := client.WriteValue(851, "GVL_Write.ComplexTypes.ENUM_", "Running")
if err != nil {
log.Fatal(err)
}
// By value
err = client.WriteValue(851, "GVL_Write.ComplexTypes.ENUM_", 100)
if err != nil {
log.Fatal(err)
}
Raw Operations
For performance-critical code or when you need direct memory access, use raw operations.
ReadRaw
Read raw bytes from PLC memory:
// Read 4 bytes from IndexGroup 0x4020, IndexOffset 0x1000
data, err := client.ReadRaw(851, 0x4020, 0x1000, 4)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Raw data: %x\n", data)
// Output: Raw data: 01020304
Getting IndexGroup and IndexOffset from symbol:
// Get symbol info first
symbol, err := client.GetSymbol(851, "GVL.MyVariable")
if err != nil {
log.Fatal(err)
}
// Use symbol info for raw read
data, err := client.ReadRaw(851, symbol.IndexGroup, symbol.IndexOffset, symbol.Size)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Read %d bytes: %x\n", len(data), data)
WriteRaw
Write raw bytes to PLC memory:
rawData := []byte{0x01, 0x02, 0x03, 0x04}
err := client.WriteRaw(851, 0x4020, 0x1000, rawData)
if err != nil {
log.Fatal(err)
}
fmt.Println("Raw data written successfully")
ReadWriteRaw
Combined read-write operation (useful for commands that require both):
writeData := []byte{0x05, 0x06}
// Write 2 bytes and read 4 bytes in one operation
readData, err := client.ReadWriteRaw(851, 0x4020, 0x1000, 4, writeData)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Read data after write: %x\n", readData)
Symbol and Type Information
Get metadata about PLC variables and data types.
GetSymbol
Retrieve symbol information (IndexGroup, IndexOffset, Size, Type):
symbol, err := client.GetSymbol(851, "GVL.MyVariable")
if err != nil {
log.Fatal(err)
}
fmt.Printf("Symbol: %s\n", symbol.Name)
fmt.Printf(" Type: %s\n", symbol.Type)
fmt.Printf(" Size: %d bytes\n", symbol.Size)
fmt.Printf(" IndexGroup: 0x%x\n", symbol.IndexGroup)
fmt.Printf(" IndexOffset: 0x%x\n", symbol.IndexOffset)
fmt.Printf(" Comment: %s\n", symbol.Comment)
/* Output:
Symbol: GVL.MyVariable
Type: INT
Size: 2 bytes
IndexGroup: 0x4020
IndexOffset: 0x1000
Comment: Counter variable
*/
GetDataType
Retrieve complete data type definition:
dataType, err := client.GetDataType("ST_MyStruct", 851)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Type: %s\n", dataType.Name)
fmt.Printf("Size: %d bytes\n", dataType.Size)
fmt.Printf("Offset: %d\n", dataType.Offset)
// Access struct fields (SubItems)
fmt.Println("Fields:")
for _, subItem := range dataType.SubItems {
fmt.Printf(" %s: %s (offset %d, size %d)\n",
subItem.Name,
subItem.Type,
subItem.Offset,
subItem.Size)
}
/* Output:
Type: ST_MyStruct
Size: 16 bytes
Offset: 0
Fields:
Field1: INT (offset 0, size 2)
Field2: BOOL (offset 2, size 1)
Field3: REAL (offset 4, size 4)
Field4: STRING(10) (offset 8, size 11)
*/
For arrays:
dataType, err := client.GetDataType("ARRAY[0..4] OF INT", 851)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Array info: %d dimensions\n", len(dataType.ArrayInfo))
for i, arrInfo := range dataType.ArrayInfo {
fmt.Printf(" Dimension %d: Length=%d, LowerBound=%d, UpperBound=%d\n",
i, arrInfo.Length, arrInfo.LowerBound, arrInfo.UpperBound)
}
/* Output:
Array info: 1 dimensions
Dimension 0: Length=5, LowerBound=0, UpperBound=4
*/
PLC Control
Control the PLC runtime state.
SetTcSystemToConfig
Set TwinCAT system to CONFIG mode (restart in config):
err := client.SetTcSystemToConfig()
if err != nil {
log.Fatal(err)
}
fmt.Println("TwinCAT system set to CONFIG mode")
SetTcSystemToRun
Set TwinCAT system to RUN mode (restart and run):
err := client.SetTcSystemToRun()
if err != nil {
log.Fatal(err)
}
fmt.Println("TwinCAT system set to RUN mode")
ReadTcSystemState
Read current TwinCAT system state:
state, err := client.ReadTcSystemState()
if err != nil {
log.Fatal(err)
}
fmt.Printf("ADS State: %d\n", state.AdsState)
fmt.Printf("Device State: %d\n", state.DeviceState)
// Common ADS states:
// 0 = Invalid
// 5 = Run
// 6 = Stop
/* Output:
ADS State: 5
Device State: 0
*/
WriteControl (Low-level)
For advanced use cases, you can use the low-level WriteControl method:
// Set to RUN state (AdsState=5, DeviceState=0)
err := client.WriteControl(5, 0, 851)
if err != nil {
log.Fatal(err)
}
// Set to STOP state (AdsState=6, DeviceState=0)
err = client.WriteControl(6, 0, 851)
if err != nil {
log.Fatal(err)
}
State Monitoring & Event Handling
The client can automatically monitor TwinCAT system state changes and detect restarts. This is useful for handling connection issues, state transitions, and TwinCAT system restarts.
Automatic State Monitoring
By default, the client checks the system state every 2 seconds and triggers event handlers when changes are detected.
Key Features:
- Detects state changes (Run ↔ Config ↔ Stop)
- Detects TwinCAT system restarts (even when state stays "Run")
- Auto-detects extended state support (TwinCAT 4022+)
- Thread-safe with automatic cleanup
OnStateChange Hook
Called whenever the TwinCAT system state changes:
settings := ads.ClientSettings{
TargetNetID: "localhost",
// Called on state changes
OnStateChange: func(client *ads.Client, newState, oldState *adsstateinfo.SystemState) {
if oldState == nil {
// Initial state after connection
fmt.Printf("Initial state: %s\n", newState.AdsState.String())
} else {
// State changed
fmt.Printf("State changed: %s → %s\n",
oldState.AdsState.String(),
newState.AdsState.String())
}
},
}
client := ads.NewClient(settings, nil)
Common State Transitions:
Run→Config: PLC stopped for configurationConfig→Run: PLC started after configurationRun→Stop: PLC stoppedStop→Run: PLC started
OnConnectionLost Hook
Called when the connection is lost unexpectedly or TwinCAT restarts:
settings := ads.ClientSettings{
TargetNetID: "localhost",
// Called when connection drops or TwinCAT restarts
OnConnectionLost: func(client *ads.Client, err error) {
fmt.Printf("Connection lost: %v\n", err)
// Re-read values and re-subscribe to notifications here
// The ADS connection is still alive, but TwinCAT restarted
},
}
client := ads.NewClient(settings, nil)
When This Is Triggered:
- TwinCAT state leaves "Run" mode (→ Config, Stop, Error, etc.)
- TwinCAT system restarts (detected via restart index change)
- Physical network connection drops
TwinCAT Restart Detection
When TwinCAT restarts using set_state run command, the ADS state may remain "Run" but subscriptions are cleared. The client detects this by monitoring the restart index from extended system state.
How It Works:
- On first state check, auto-detects extended state support
- Monitors both
AdsStateANDRestartIndexon each poll - When
RestartIndexchanges → triggersOnConnectionLost - Works with TwinCAT 4022 and newer (gracefully falls back on older versions)
Example Log Output:
TwinCAT system restarted (restart index: 44 → 48)
EVENT: TwinCAT system state changed fromState=Run toState=Run
EVENT: ADS connection lost unexpectedly
Reading Extended System State
For TwinCAT 4022 and newer, you can read extended system information including the restart index:
extState, err := client.ReadTcSystemExtendedState()
if err != nil {
// Extended state not supported or error
log.Printf("Extended state not available: %v", err)
} else {
fmt.Printf("Restart Index: %d\n", extState.RestartIndex)
fmt.Printf("TwinCAT Version: %d.%d.%d\n",
extState.Version, extState.Revision, extState.Build)
fmt.Printf("Platform: %d, OS Type: %d\n",
extState.Platform, extState.OsType)
}
/* Output:
Restart Index: 48
TwinCAT Version: 3.1.4024
Platform: 1, OS Type: 2
*/
Extended State Fields:
RestartIndex(uint16): Increments on every TwinCAT restartVersion,Revision,Build: TwinCAT version informationPlatform: Platform identifier (1=PC, 5=ARM, etc.)OsType: Operating system type (2=Windows, 10=Linux, etc.)Flags: System service state flags
Getting Current State
Retrieve the cached current state (updated by background monitoring):
currentState := client.GetCurrentState()
if currentState == nil {
fmt.Println("State not available yet (still initializing)")
} else {
fmt.Printf("Current state: %s\n", currentState.AdsState.String())
// Check if PLC is running
if currentState.AdsState == types.ADSStateRun {
fmt.Println("PLC is running - operations available")
}
}
Customizing State Polling
Change the polling interval (default is 2 seconds):
settings := ads.ClientSettings{
TargetNetID: "localhost",
// Check state every 5 seconds
StatePollingInterval: 5 * time.Second,
}
client := ads.NewClient(settings, nil)
Disable state monitoring:
settings := ads.ClientSettings{
TargetNetID: "localhost",
// Disable automatic state monitoring
StatePollingInterval: 0,
}
client := ads.NewClient(settings, nil)
Complete Example
Here's a complete example with state monitoring and reconnection logic:
package main
import (
"fmt"
"log"
"time"
"github.com/jarmocluyse/ads-go/pkg/ads"
"github.com/jarmocluyse/ads-go/pkg/ads/ads-stateinfo"
"github.com/jarmocluyse/ads-go/pkg/ads/types"
)
func main() {
settings := ads.ClientSettings{
TargetNetID: "localhost",
// Monitor state changes
OnStateChange: func(client *ads.Client, newState, oldState *adsstateinfo.SystemState) {
if oldState == nil {
fmt.Printf("Initial state: %s\n", newState.AdsState.String())
return
}
fmt.Printf("State changed: %s → %s\n",
oldState.AdsState.String(),
newState.AdsState.String())
// Detect Run mode entry
if newState.AdsState == types.ADSStateRun &&
oldState.AdsState != types.ADSStateRun {
fmt.Println("TwinCAT entered RUN mode")
// Re-initialize your application logic here
}
},
// Handle connection loss / restart
OnConnectionLost: func(client *ads.Client, err error) {
fmt.Printf("Connection lost: %v\n", err)
// Wait for TwinCAT to come back to Run mode
fmt.Println("Waiting for TwinCAT to return to Run mode...")
for {
time.Sleep(1 * time.Second)
state := client.GetCurrentState()
if state != nil && state.AdsState == types.ADSStateRun {
fmt.Println("TwinCAT back in Run mode!")
// Re-read values and re-subscribe here
// Example: resubscribeToNotifications(client)
break
}
}
},
}
client := ads.NewClient(settings, nil)
if err := client.Connect(); err != nil {
log.Fatal(err)
}
defer client.Disconnect()
fmt.Println("Connected - monitoring state changes...")
// Your application logic here
select {} // Keep running
}
Key Points:
- State monitoring runs automatically in the background
- Hooks are called asynchronously (don't block)
- ADS connection stays alive during TwinCAT restarts
- User must re-read values and re-subscribe after restart
GetCurrentState()returns cached state (no network call)
Subscriptions & Notifications
The client supports ADS notifications (subscriptions) for monitoring variable value changes in real-time. Instead of polling variables, you can subscribe to them and receive automatic notifications when values change.
Key Features
- Event-driven monitoring - Get notified only when values change
- Configurable cycle times - Control how often values are checked (default: 100ms)
- Change detection - Option to send notifications only on value changes
- Multiple subscriptions - Subscribe to many variables simultaneously
- Thread-safe - Safe for concurrent access
- Automatic cleanup - Subscriptions are cleared on disconnect
Basic Subscription
Subscribe to a variable and receive notifications when it changes:
package main
import (
"fmt"
"log"
"time"
"github.com/jarmocluyse/ads-go/pkg/ads"
)
func main() {
client := ads.NewClient(ads.ClientSettings{
TargetNetID: "localhost",
}, nil)
if err := client.Connect(); err != nil {
log.Fatal(err)
}
defer client.Disconnect()
// Define callback function
callback := func(data ads.SubscriptionData) {
fmt.Printf("Value changed: %v (at %s)\n",
data.Value,
data.Timestamp.Format("15:04:05.000"))
}
// Subscribe to a variable
settings := ads.SubscriptionSettings{
CycleTime: 100 * time.Millisecond,
SendOnChange: true,
}
sub, err := client.SubscribeValue(851, "GVL.Counter", callback, settings)
if err != nil {
log.Fatal(err)
}
fmt.Println("Subscribed! Waiting for notifications...")
// Keep running to receive notifications
time.Sleep(30 * time.Second)
// Unsubscribe when done
if err := client.Unsubscribe(sub); err != nil {
log.Printf("Error unsubscribing: %v", err)
}
}
/* Output:
Subscribed! Waiting for notifications...
Value changed: 10 (at 14:23:15.123)
Value changed: 11 (at 14:23:15.223)
Value changed: 12 (at 14:23:15.323)
...
*/
Subscription Settings
Control how notifications are sent using SubscriptionSettings:
settings := ads.SubscriptionSettings{
// How often to check the variable (required)
CycleTime: 100 * time.Millisecond,
// Only send notifications when value changes (default: false)
// If false, notifications are sent every CycleTime
SendOnChange: true,
}
Recommended settings:
// Fast-changing values (motors, sensors)
fastSettings := ads.SubscriptionSettings{
CycleTime: 50 * time.Millisecond,
SendOnChange: true,
}
// Slow-changing values (temperature, status)
slowSettings := ads.SubscriptionSettings{
CycleTime: 1 * time.Second,
SendOnChange: true,
}
// Always notify (regardless of change)
alwaysSettings := ads.SubscriptionSettings{
CycleTime: 100 * time.Millisecond,
SendOnChange: false, // Sends every 100ms
}
Subscription Data
The callback receives SubscriptionData with the following fields:
type SubscriptionData struct {
Value any // The variable value (with type conversion)
Timestamp time.Time // When the notification was received
}
Example callback with type assertion:
callback := func(data ads.SubscriptionData) {
// Type assert to expected type
if intValue, ok := data.Value.(int32); ok {
fmt.Printf("Counter: %d\n", intValue)
}
// Or handle multiple types
switch v := data.Value.(type) {
case int32:
fmt.Printf("Integer: %d\n", v)
case bool:
fmt.Printf("Boolean: %v\n", v)
case float32:
fmt.Printf("Float: %.2f\n", v)
default:
fmt.Printf("Unknown type: %v\n", v)
}
}
Multiple Subscriptions
Subscribe to multiple variables at once:
// Track subscriptions
var subscriptions []*ads.ActiveSubscription
// Subscribe to multiple variables
variables := []string{
"GVL.Counter",
"GVL.Temperature",
"GVL.IsRunning",
"GVL.ErrorCode",
}
for _, varName := range variables {
// Create callback for this variable
callback := func(name string) ads.SubscriptionCallback {
return func(data ads.SubscriptionData) {
fmt.Printf("[%s] = %v\n", name, data.Value)
}
}(varName)
// Subscribe
settings := ads.SubscriptionSettings{
CycleTime: 100 * time.Millisecond,
SendOnChange: true,
}
sub, err := client.SubscribeValue(851, varName, callback, settings)
if err != nil {
log.Printf("Failed to subscribe to %s: %v", varName, err)
continue
}
subscriptions = append(subscriptions, sub)
fmt.Printf("Subscribed to %s\n", varName)
}
// Later: unsubscribe from all
for _, sub := range subscriptions {
if err := client.Unsubscribe(sub); err != nil {
log.Printf("Error unsubscribing: %v", err)
}
}
Unsubscribing
Unsubscribe from a specific subscription:
sub, err := client.SubscribeValue(851, "GVL.Counter", callback, settings)
if err != nil {
log.Fatal(err)
}
// ... later ...
if err := client.Unsubscribe(sub); err != nil {
log.Printf("Error unsubscribing: %v", err)
}
Unsubscribe from all active subscriptions:
if err := client.UnsubscribeAll(); err != nil {
log.Printf("Error unsubscribing from all: %v", err)
}
Note: All subscriptions are automatically cleared when:
Disconnect()is called- TwinCAT system restarts (use
OnConnectionLosthook to re-subscribe)
Handling TwinCAT Restarts
When TwinCAT restarts, all subscriptions are cleared. Use the OnConnectionLost hook to automatically re-subscribe:
// Track active subscriptions for re-subscription
var activeVars = []string{"GVL.Counter", "GVL.Temperature"}
settings := ads.ClientSettings{
TargetNetID: "localhost",
// Re-subscribe after TwinCAT restart
OnConnectionLost: func(client *ads.Client, err error) {
fmt.Printf("Connection lost: %v\n", err)
fmt.Println("Waiting for TwinCAT to return to Run mode...")
// Wait for Run state
for {
time.Sleep(1 * time.Second)
state := client.GetCurrentState()
if state != nil && state.AdsState == types.ADSStateRun {
fmt.Println("TwinCAT back in Run mode - re-subscribing...")
// Re-subscribe to all variables
for _, varName := range activeVars {
callback := func(data ads.SubscriptionData) {
fmt.Printf("[%s] = %v\n", varName, data.Value)
}
subSettings := ads.SubscriptionSettings{
CycleTime: 100 * time.Millisecond,
SendOnChange: true,
}
if _, err := client.SubscribeValue(851, varName, callback, subSettings); err != nil {
log.Printf("Failed to re-subscribe to %s: %v", varName, err)
} else {
fmt.Printf("Re-subscribed to %s\n", varName)
}
}
break
}
}
},
}
client := ads.NewClient(settings, nil)
Complete Working Example
Here's a complete example with subscriptions and proper lifecycle management:
package main
import (
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/jarmocluyse/ads-go/pkg/ads"
"github.com/jarmocluyse/ads-go/pkg/ads/types"
)
func main() {
// Track subscriptions for cleanup
var subscriptions []*ads.ActiveSubscription
settings := ads.ClientSettings{
TargetNetID: "localhost",
// Handle TwinCAT restarts
OnConnectionLost: func(client *ads.Client, err error) {
fmt.Printf("Connection lost: %v\n", err)
// Wait for Run state and re-subscribe
for {
time.Sleep(1 * time.Second)
state := client.GetCurrentState()
if state != nil && state.AdsState == types.ADSStateRun {
fmt.Println("Re-subscribing...")
subscribeToVariables(client, &subscriptions)
break
}
}
},
}
client := ads.NewClient(settings, nil)
if err := client.Connect(); err != nil {
log.Fatal(err)
}
defer client.Disconnect()
fmt.Println("Connected! Creating subscriptions...")
// Initial subscriptions
subscribeToVariables(client, &subscriptions)
fmt.Println("\nMonitoring variables. Press Ctrl+C to exit...")
// Wait for interrupt signal
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
<-sigChan
fmt.Println("\nShutting down...")
}
func subscribeToVariables(client *ads.Client, subscriptions *[]*ads.ActiveSubscription) {
// Clear old subscriptions
*subscriptions = nil
variables := map[string]ads.SubscriptionSettings{
"GVL.Counter": {
CycleTime: 100 * time.Millisecond,
SendOnChange: true,
},
"GVL.Temperature": {
CycleTime: 500 * time.Millisecond,
SendOnChange: true,
},
"GVL.IsRunning": {
CycleTime: 200 * time.Millisecond,
SendOnChange: true,
},
}
for varName, settings := range variables {
// Create callback for this variable
callback := func(name string) ads.SubscriptionCallback {
return func(data ads.SubscriptionData) {
fmt.Printf("[%s] %s = %v\n",
data.Timestamp.Format("15:04:05.000"),
name,
data.Value)
}
}(varName)
// Subscribe
sub, err := client.SubscribeValue(851, varName, callback, settings)
if err != nil {
log.Printf("Failed to subscribe to %s: %v", varName, err)
continue
}
*subscriptions = append(*subscriptions, sub)
fmt.Printf("✓ Subscribed to %s\n", varName)
}
}
Subscription Lifecycle
1. Connect to PLC
↓
2. Subscribe to variables
↓
3. Receive notifications automatically
↓
4. [TwinCAT restarts] → OnConnectionLost triggered
↓
5. Wait for Run state
↓
6. Re-subscribe to variables
↓
7. Continue receiving notifications
↓
8. Disconnect (automatic cleanup)
Performance Considerations
Cycle Time:
- Shorter cycle times = more frequent checks = higher CPU usage
- Recommended minimum: 50ms
- Default: 100ms
- For slow-changing values: 500ms - 1s
Send On Change:
- Always enable
SendOnChange: truewhen possible - Reduces network traffic significantly
- Only use
SendOnChange: falsewhen you need guaranteed periodic updates
Number of Subscriptions:
- The client can handle many simultaneous subscriptions
- Each subscription is managed independently
- TwinCAT may have limits (typically hundreds of subscriptions)
Common Patterns
Subscribe to struct fields:
// Subscribe to individual fields
callback := func(data ads.SubscriptionData) {
structValue := data.Value.(map[string]any)
field1 := structValue["Field1"].(int32)
field2 := structValue["Field2"].(bool)
fmt.Printf("Field1=%d, Field2=%v\n", field1, field2)
}
settings := ads.SubscriptionSettings{
CycleTime: 100 * time.Millisecond,
SendOnChange: true,
}
sub, err := client.SubscribeValue(851, "GVL.MyStruct", callback, settings)
Subscribe to array elements:
// Subscribe to entire array
callback := func(data ads.SubscriptionData) {
arrayValue := data.Value.([]any)
fmt.Printf("Array length: %d\n", len(arrayValue))
for i, item := range arrayValue {
fmt.Printf(" [%d] = %v\n", i, item)
}
}
sub, err := client.SubscribeValue(851, "GVL.MyArray", callback, settings)
Conditional notifications:
// Only log when value exceeds threshold
callback := func(data ads.SubscriptionData) {
if temperature, ok := data.Value.(float32); ok {
if temperature > 80.0 {
fmt.Printf("⚠️ High temperature: %.1f°C\n", temperature)
}
}
}
Troubleshooting
Notifications not received:
- Verify PLC is in RUN mode (
GetCurrentState()) - Check that the variable path is correct
- Ensure
CycleTimeis not too long - Verify the variable value is actually changing
Too many notifications:
- Increase
CycleTimeto reduce frequency - Enable
SendOnChange: trueto filter unchanged values - Consider if you really need such frequent updates
Subscriptions lost after restart:
- This is expected behavior when TwinCAT restarts
- Use
OnConnectionLosthook to re-subscribe automatically - See "Handling TwinCAT Restarts" section above
Device Information
Read information about the target device:
info, err := client.ReadDeviceInfo()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Device Name: %s\n", info.DeviceName)
fmt.Printf("Version: %d.%d (Build %d)\n",
info.MajorVersion,
info.MinorVersion,
info.VersionBuild)
/* Output:
Device Name: PLC-1
Version: 3.1 (Build 4024)
*/
Logging
The client uses structured logging via Go's standard log/slog package. By default, logging is disabled.
Enable Logging
Text output to console:
import "log/slog"
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
client := ads.NewClient(settings, logger)
JSON output:
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
client := ads.NewClient(settings, logger)
Custom log levels:
logLevel := &slog.LevelVar{}
logLevel.Set(slog.LevelWarn) // Only warnings and errors
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: logLevel,
})
logger := slog.New(handler)
client := ads.NewClient(settings, logger)
Disable Logging (default)
client := ads.NewClient(settings, nil) // No logging
Disconnecting
Always disconnect when done to clean up resources:
if err := client.Disconnect(); err != nil {
log.Printf("Error during disconnect: %v", err)
}
Using defer (recommended):
func main() {
client := ads.NewClient(settings, nil)
if err := client.Connect(); err != nil {
log.Fatal(err)
}
defer client.Disconnect()
// Your code here...
// Disconnect will be called automatically on exit
}
Common Issues and Questions
Connection timeouts or failures
Symptoms:
- Connection fails immediately
- Timeout errors after 2 minutes
- "Connection refused" errors
Solutions:
- Verify the target PLC is reachable (ping the IP address)
- Check that TwinCAT is running on the target
- Verify firewall allows TCP port 48898 (ADS router port)
- On Windows, ensure Ethernet connection is set to "Private" network
- For direct connections (Setup 3), verify StaticRoutes.xml is configured correctly
- Check that the AmsNetId and ADS port are correct
Symbol not found errors
Symptoms:
- Error message: "symbol not found" or similar
- ReadValue/WriteValue fails
Solutions:
- Verify the variable name and path are correct (case-sensitive in TC3, UPPERCASE in TC2)
- Check that the PLC runtime is in RUN mode (some symbols unavailable in CONFIG)
- Verify the variable is not optimized away by the compiler
- For TwinCAT 2, ensure you're using the correct syntax (dot prefix for globals)
- Try using ReadRaw with GetSymbol to get more details
Connecting to localhost not working
Symptoms:
- Cannot connect when using
TargetNetID: "localhost"or"127.0.0.1.1.1" - Connection refused on local machine
Solutions:
- Enable TCP loopback in registry (see Enabling localhost support)
- TwinCAT versions < 4024.5 require manual registry edit
- Restart Windows after changing registry
- Verify TwinCAT is running locally
Config mode connections
Symptoms:
- Cannot read/write values when PLC is in CONFIG mode
- "Target port not found" errors
Solutions:
- This is expected behavior - most PLC runtime features require RUN mode
- Use
SetTcSystemToRun()to start the PLC - For system-level operations, you can still read device info and state
TwinCAT 2 variable names
Symptoms:
- Symbol not found when using TwinCAT 2
- Variables not accessible
Solutions:
- All variable names must be UPPERCASE in TwinCAT 2
- Global variables need dot prefix:
.VARIABLENAME - Use port 801 instead of 851
- See Differences when using with TwinCAT 2
Connection from Raspberry Pi or Linux
Symptoms:
- Cannot connect from Linux system
- No router available
Solutions:
- Use Setup 3 (direct connection) - see Setup 3
- Configure StaticRoutes.xml on the target PLC
- Ensure your LocalAmsNetId is unique and not used by other devices
- No TwinCAT installation needed on the Linux system
Port already in use
Symptoms:
- Error about port being in use
- Cannot start second client
Solutions:
- Use a different
LocalAdsPortfor each client instance - Ensure previous client disconnected properly
- Wait a few seconds for the OS to release the port
Architecture
The ads-go library uses a modular architecture for maintainability and testing.
Modular Design
The package is organized into submodules, each handling a specific aspect of the ADS protocol:
| Module | Purpose | Test Coverage |
|---|---|---|
| ads-errors | Parse and validate 4-byte ADS error codes | 100% |
| ads-header | Parse 8-byte ADS response headers | 100% |
| ads-symbol | Parse ADS symbol information | 100% |
| ads-datatype | Parse complex data type definitions | 100% |
| ads-stateinfo | Parse system state and device info | 100% |
| ads-primitives | Read/write primitive types | 84.8% |
| ads-requests | Build ADS command payloads | 100% |
| ads-serializer | Type serialization and deserialization | 57.9% |
| ams-header | Parse AMS protocol packet headers | 100% |
| ams-builder | Build AMS/TCP and AMS headers | 100% |
Design Patterns
Invoke ID Management:
- Each request gets a unique invoke ID
- Responses are matched to requests via invoke ID
- Ensures correct handling of concurrent operations
Goroutine Receive Loop:
- Dedicated goroutine for receiving AMS packets
- Channel-based communication with request handlers
- Automatic buffer management and packet reassembly
Modular Parsing:
- Each protocol layer has dedicated parser
- Easy to test and maintain
- Clear separation of concerns
Roadmap
The following features are planned for future releases:
✅ ADS Notifications (Subscriptions) - IMPLEMENTED
Event-driven value monitoring:
- ✅ Subscribe to variable value changes
- ✅ Automatic notification handling
- ✅ Multiple simultaneous subscriptions
- ✅ Configurable cycle times and change thresholds
Status: ✅ Complete - See Subscriptions & Notifications section
Variable Handle Management
Improve performance for repeated reads/writes:
- Create/delete variable handles
- Read/write using handles (faster than by path)
- Automatic handle caching
- Handle lifecycle management
Status: Index groups defined, not actively used
RPC Method Invocation
Call PLC function block methods:
- Invoke FB methods with parameters
- Support for input/output parameters
- Return value handling
- Method metadata parsing
Status: Method metadata is parsed, invocation not implemented
Batch Operations (Sum Commands)
Improve performance for multiple operations:
- Read multiple values in one packet
- Write multiple values in one packet
- Reduced network overhead
- Single round-trip for many operations
Status: Index groups defined, not implemented
Contributing
Contributions, issues, and feature requests are welcome! Please see our Contributing Guide for details on how to get started.
Quick Links:
- Contributing Guide - How to contribute to this project
- Code of Conduct - Our community standards
- Security Policy - How to report security vulnerabilities
- Report bugs - GitHub Issues
- Suggest features - GitHub Discussions
Testing
Running Tests
Run all tests:
go test ./pkg/ads/... -v
Run tests with coverage:
go test ./pkg/ads/... -cover
Generate coverage report:
go test ./pkg/ads/... -coverprofile=coverage.out
go tool cover -html=coverage.out
Run specific test:
go test ./pkg/ads/ads-serializer/... -v -run TestSerialize
Test Structure
The project uses table-driven tests with clear test cases:
- Unit tests for each module
- Integration tests for client operations
- Guard clause style (early returns)
github.com/stretchr/testify/assertfor assertions
Examples
Complete working examples can be found in:
Command-Line Interface (CLI)
Location: cmd/main.go
The CLI provides an interactive interface for testing and demonstrating the ads-go library features.
Running the CLI
cd cmd
go run main.go
Or use the pre-built binary:
./cmd/ads-cli
CLI Features
Visual Status Indicators:
- 🟢 Green prompt = PLC running (operations available)
- 🔵 Blue prompt = PLC in config mode
- 🔴 Red prompt = PLC stopped
- ⚪ White prompt = Disconnected or initializing
Intelligent Autocomplete:
- Command completion with TAB key
- Argument suggestions for commands (e.g.,
write_bool <TAB>→true,false) - Variable path suggestions for
subscribecommand (14 common paths) - Dynamic subscription ID completions for
unsubscribe - Object field suggestions for
write_object(Counter=, Ready=)
Enhanced Subscription Management:
- Real-time notifications with timestamps
- Subscription statistics (last value, update time, notification count)
- Quick subscription shortcuts for common variables
- Multiple simultaneous subscriptions
- Enhanced list view with detailed information
Interactive Features:
- Command history navigation (use arrow keys)
- Auto-reconnection on connection loss
- Automatic state change detection
- Connection lifecycle hooks
Available Commands
System Commands
device_info- Get device informationstate- Read current TwinCAT statestate_loop- Continuously monitor TwinCAT statemonitor- Monitor system notificationsset_state <config|run>- Switch TwinCAT state
Read/Write Commands
read_value- ReadGLOBAL.gMyIntread_bool- ReadGLOBAL.gMyBoolread_object- ReadGLOBAL.gMyDUT(struct)read_array- ReadGLOBAL.gIntArraylist_symbols- List all available PLC symbols (first 100)write_value <int>- Write integer toGLOBAL.gMyIntwrite_bool <true|false>- Write boolean toGLOBAL.gMyBoolwrite_object Counter=<int> Ready=<bool>- Write toGLOBAL.gMyDUTwrite_array <i1> <i2> <i3> <i4> <i5>- Write 5 ints toGLOBAL.gIntArray
Subscription Commands
subscribe [path]- Subscribe to variable changes (default:GLOBAL.gMyBoolToogle)list_subs- List active subscriptions with statisticsunsubscribe <id>- Remove specific subscriptionunsubscribe_all- Remove all subscriptions
Subscription Shortcuts (Quick subscriptions for example project)
sub_counter- Subscribe to cycle-based counter (GLOBAL.gMyIntCounter)sub_toggle- Subscribe to cycle-based toggle (GLOBAL.gMyBoolToogle)sub_timed_counter- Subscribe to time-based counter (GLOBAL.gTimedIntCounter)sub_timed_toggle- Subscribe to time-based toggle (GLOBAL.gTimedBoolToogle)sub_all- Subscribe to all 4 counters/toggles at once
Control Commands (For example project)
enable_counter <bool>- Enable/disable cycle-based counterenable_toggle <bool>- Enable/disable cycle-based toggleenable_timed_counter <bool>- Enable/disable time-based counterenable_timed_toggle <bool>- Enable/disable time-based toggleread_counters- Read all counter and toggle valuesreset_counters- Reset all counters to zeroread_status- Show enable flag statesset_period <seconds>- Set cycle period (1-3600s, default 2s)read_period- Read current cycle period
Example TwinCAT Project
Location: example/example/
The CLI works with an included TwinCAT 3 project that demonstrates various features:
Available Variables:
GLOBAL.gMyInt,GLOBAL.gMyBool,GLOBAL.gMyDINT- Basic types for testingGLOBAL.gMyIntCounter- Counter increments every PLC scanGLOBAL.gMyBoolToogle- Boolean toggles every PLC scanGLOBAL.gTimedIntCounter- Counter increments every cycle period (default 2s)GLOBAL.gTimedBoolToogle- Boolean toggles every cycle periodGLOBAL.gIntArray- Array of 101 integers (CLI writes to first 5)GLOBAL.gMyDUT- Structured data (Counter: INT, Ready: BOOL, gIntArray: ARRAY[0..50] OF INT)GLOBAL.gCyclePeriod- Configurable timer period (TIME type, default T#2S)
Control Flags:
GLOBAL.gIntCounterActive- Enable/disable cycle-based counter (default TRUE)GLOBAL.gBoolToggleActive- Enable/disable cycle-based toggle (default TRUE)GLOBAL.gTimedCounterActive- Enable/disable time-based counter (default TRUE)GLOBAL.gTimedToggleActive- Enable/disable time-based toggle (default TRUE)
The project includes:
- Cycle-based logic that runs every PLC scan
- Time-based logic triggered by configurable timer
- All variables accessible via ADS for read/write operations
- Perfect for testing subscriptions and real-time updates
Example Usage
Quick start with subscriptions:
# Start the CLI
./ads-cli
# Subscribe to a fast-changing counter
sub_counter
# Subscribe to all counters/toggles at once
sub_all
# View subscription statistics with last values
list_subs
# Disable the cycle-based toggle
enable_toggle false
# Change timer period to 5 seconds
set_period 5
# Remove a specific subscription
unsubscribe 1
# Remove all subscriptions
unsubscribe_all
Testing variable operations:
# Write a value
write_value 42
# Read it back
read_value
# Write a boolean
write_bool true
# Write a structured object
write_object Counter=100 Ready=true
# Write an array (first 5 elements)
write_array 10 20 30 40 50
# Read the array back
read_array
Monitoring system state:
# Check current state
state
# Monitor state continuously (Ctrl+C to stop)
state_loop
# Switch to config mode
set_state config
# Return to run mode
set_state run
# Get device information
device_info
Using autocomplete:
# Type 'sub_' and press TAB to see subscription shortcuts
sub_<TAB>
# Type 'write_bool ' and press TAB to see options
write_bool <TAB>
# Type 'subscribe ' and press TAB to see common variable paths
subscribe <TAB>
# Type 'unsubscribe ' and press TAB to see active subscription IDs
unsubscribe <TAB>
Additional Examples
- example/ - TwinCAT 3 example project with PLC program
License
This project is licensed under the MIT License - see the LICENSE file for details.
Credits
- Author: Jarmo Cluyse ([email protected])
- GitHub: https://github.com/jarmocluyse/ads-go
- Documentation inspired by: jisotalo/ads-client by Jussi Isotalo (used with permission)
Made with ❤️ for the Beckhoff automation community
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
module
|
|
|
pkg
|
|
|
ads
Package ads provides a Go client library for Beckhoff TwinCAT ADS protocol.
|
Package ads provides a Go client library for Beckhoff TwinCAT ADS protocol. |
|
ads/ads-datatype
Package adsdatatype provides parsing functionality for ADS data type information responses.
|
Package adsdatatype provides parsing functionality for ADS data type information responses. |
|
ads/ads-errors
Package adserrors provides error handling for the ADS protocol.
|
Package adserrors provides error handling for the ADS protocol. |
|
ads/ads-header
Package adsheader provides parsing and validation for ADS response headers.
|
Package adsheader provides parsing and validation for ADS response headers. |
|
ads/ads-primitives
Package adsprimitives provides functions for reading and writing primitive data types from/to binary ADS protocol data.
|
Package adsprimitives provides functions for reading and writing primitive data types from/to binary ADS protocol data. |
|
ads/ads-requests
Package adsrequests provides functions for building binary payloads for ADS (Automation Device Specification) commands.
|
Package adsrequests provides functions for building binary payloads for ADS (Automation Device Specification) commands. |
|
ads/ads-serializer
Package adsserializer provides serialization and deserialization of ADS data types.
|
Package adsserializer provides serialization and deserialization of ADS data types. |
|
ads/ads-stateinfo
Package adsstateinfo provides parsing and validation for ADS system state and device information.
|
Package adsstateinfo provides parsing and validation for ADS system state and device information. |
|
ads/ads-symbol
Package adssymbol provides parsing and validation for ADS symbol information.
|
Package adssymbol provides parsing and validation for ADS symbol information. |
|
ads/ams-builder
Package amsbuilder provides functions for building AMS/TCP and AMS protocol headers.
|
Package amsbuilder provides functions for building AMS/TCP and AMS protocol headers. |
|
ads/ams-header
Package amsheader provides parsing functionality for AMS (Automation Message Specification) packet headers.
|
Package amsheader provides parsing functionality for AMS (Automation Message Specification) packet headers. |