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).
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)
- 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)
- Support
- Installing
- Minimal Example (TLDR)
- Connection Setup
- Important
- Getting Started
- Common Issues and Questions
- Architecture
- Roadmap
- Testing
- Examples
- License
- Issues & bugs: GitHub Issues
- Discussions & help: GitHub Discussions
go get github.com/jarmocluyse/ads-go@latestImport in your code:
import "github.com/jarmocluyse/ads-go/pkg/ads"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!")
}The ads-go client can be used with multiple system configurations.
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)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)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)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)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.
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.
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.
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.
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.
| 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 |
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)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...
}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: 32767Reading 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: trueReading 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.14Reading 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 PLCStructs 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 ...]
*/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
*/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 = 100Always 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)
}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)
}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)
}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)
}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)
}For performance-critical code or when you need direct memory access, use raw operations.
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: 01020304Getting 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)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")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)Get metadata about PLC variables and data types.
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
*/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
*/Control the PLC runtime state.
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")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")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
*/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)
}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.
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
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
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
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
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
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")
}
}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)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)
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.
- 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
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)
...
*/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
}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)
}
}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)
}
}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)
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)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)
}
}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)
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)
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)
}
}
}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
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)
*/The client uses structured logging via Go's standard log/slog package. By default, logging is disabled.
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)client := ads.NewClient(settings, nil) // No loggingAlways 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
}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
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
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
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
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
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
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
The ads-go library uses a modular architecture for maintainability and testing.
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% |
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
The following features are planned for future releases:
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
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
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
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
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
Run all tests:
go test ./pkg/ads/... -vRun tests with coverage:
go test ./pkg/ads/... -coverGenerate coverage report:
go test ./pkg/ads/... -coverprofile=coverage.out
go tool cover -html=coverage.outRun specific test:
go test ./pkg/ads/ads-serializer/... -v -run TestSerializeThe 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
Complete working examples can be found in:
Location: cmd/main.go
The CLI provides an interactive interface for testing and demonstrating the ads-go library features.
cd cmd
go run main.goOr use the pre-built binary:
./cmd/ads-cliVisual 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
device_info- Get device informationstate- Read current TwinCAT statestate_loop- Continuously monitor TwinCAT statemonitor- Monitor system notificationsset_state <config|run>- Switch TwinCAT state
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
subscribe [path]- Subscribe to variable changes (default:GLOBAL.gMyBoolToogle)list_subs- List active subscriptions with statisticsunsubscribe <id>- Remove specific subscriptionunsubscribe_all- Remove all subscriptions
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
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
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
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_allTesting 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_arrayMonitoring 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_infoUsing 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>- example/ - TwinCAT 3 example project with PLC program
This project is licensed under the MIT License - see the LICENSE file for details.
- Author: Jarmo Cluyse (jarmo_cluyse@hotmail.com)
- 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


