Read and watch a TwinCAT PLC symbol for changes (pyads)
Beckhoff's ADS protocol has the same two halves as OPC UA: read a value once, or subscribe and get told when it changes. This script does both — an initial read_by_name snapshot, then a device notification that logs every change to CSV using pyads's @plc.notification() decorator, which handles the byte-level decoding for you so the callback just receives a plain Python value. Honest note: pyads requires TwinCAT's ADS router even to import on Windows, so I reviewed this line by line against the library's own documented API and source rather than running it end to end here — test the shape yourself against pyads.testserver before pointing it at a real PLC.
Before you run it
pip install pyads- An AMS route from this PC to the TwinCAT PLC — set up via TwinCAT's Static Routes dialog, or
pyads.add_route()from Python — the connection fails silently without one - The symbol's exact PLC datatype (
PLCTYPE_LREAL,PLCTYPE_INT, …) — reading or subscribing with the wrong type returns garbage, not an error
The code
"""Read a TwinCAT PLC symbol once, then log every change via a device
notification (pyads).
Usage: python pyads_symbol_logger.py 5.24.34.100.1.1 MAIN.spindleSpeed out.csv 30
"""
import csv
import sys
import time
from ctypes import sizeof
import pyads
PORT = pyads.PORT_TC3PLC1
PLC_TYPE = pyads.PLCTYPE_LREAL
def main():
if len(sys.argv) < 4:
sys.exit(
"Usage: python pyads_symbol_logger.py <ams-net-id> <symbol-name> "
"<csv-path> [seconds]"
)
ams_net_id, symbol, csv_path = sys.argv[1:4]
seconds = int(sys.argv[4]) if len(sys.argv) > 4 else 30
plc = pyads.Connection(ams_net_id, PORT)
with open(csv_path, "w", newline="") as fh, plc:
writer = csv.writer(fh)
writer.writerow(["timestamp", "symbol", "value"])
initial = plc.read_by_name(symbol, PLC_TYPE)
print(f"initial {symbol} = {initial}")
writer.writerow([time.time(), symbol, initial])
@plc.notification(PLC_TYPE)
def on_change(handle, name, timestamp, value):
print(f"{timestamp} {name} = {value}")
writer.writerow([timestamp, name, value])
fh.flush()
# ADSTRANS_SERVERONCHA (the default) fires only when the value
# actually changes, not on a fixed poll interval.
attr = pyads.NotificationAttrib(sizeof(PLC_TYPE))
handles = plc.add_device_notification(symbol, attr, on_change)
print(f"Watching {symbol} for {seconds}s - Ctrl+C to stop early.")
try:
time.sleep(seconds)
finally:
plc.del_device_notification(*handles)
if __name__ == "__main__":
main()What you get
What you get
An immediate printed snapshot of the symbol's current value, then one
printed line plus one CSV row every time it changes:
initial MAIN.spindleSpeed = 0.0
2026-07-13 14:05:02.118000 MAIN.spindleSpeed = 4200.0
2026-07-13 14:05:07.402000 MAIN.spindleSpeed = 4210.0How it works
@plc.notification(PLC_TYPE)decorates a plain(handle, name, timestamp, value)function and hands back a wrapperadd_device_notificationactually calls — the decorator's job is converting the raw notification bytes intoPLC_TYPEfor you, so the callback body never touchesctypes.NotificationAttrib(sizeof(PLC_TYPE))leavestrans_modeat its default,ADSTRANS_SERVERONCHA— on-change, not a fixed cycle — which is exactly "log every change" and the reason this script doesn't need its own polling loop.- The initial
read_by_namecall exists so the CSV has a starting value even if the symbol doesn't change for a while after the script starts — subscriptions only tell you about changes, never the current state at subscribe time. fh.flush()inside the callback matters more than it looks: without it, rows sit in Python's buffer and a script that'sCtrl+C'd mid-run can lose the last several changes from the file.
Gotchas & honest limits
- `add_device_notification` returns a 2-tuple (notification handle, user handle), and
del_device_notificationtakes them as two separate arguments — the library's own docstring example passes the tuple straight through as one argument, which doesn't match the method's actual signature. Unpack it:plc.del_device_notification(*handles). - The AMS route is the part everyone fights. An AMS Net ID is not an IP address (
5.24.34.100.1.1, not5.24.34.100) — it has to be registered as a route on both the PC and the PLC before any connection succeeds, and a missing route fails with a generic timeout, not a clear "no route" error. - Getting
PLC_TYPEwrong is silent corruption, not a crash — reading anLREALas anINTreturns a number, just the wrong one, sliced from the wrong byte range. - This sample was reviewed against
pyads's documented API and source, not executed against real hardware orpyads.testserverin this environment — treat it as a strong starting point, not a guarantee, and validate against your own PLC before relying on it.
Goes deeper
Want this adapted to your shop — or built into a real tool?
Samples are the free 80%. The last 20% is the part I do for a living.