Code Library
Machine DataPythonPython 3.8+ · pyadsMIT licenseIntermediate

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

GitHub
"""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.0

How it works

  • @plc.notification(PLC_TYPE) decorates a plain (handle, name, timestamp, value) function and hands back a wrapper add_device_notification actually calls — the decorator's job is converting the raw notification bytes into PLC_TYPE for you, so the callback body never touches ctypes.
  • NotificationAttrib(sizeof(PLC_TYPE)) leaves trans_mode at its default, ADSTRANS_SERVERONCHAon-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_name call 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's Ctrl+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_notification takes 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, not 5.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_TYPE wrong is silent corruption, not a crash — reading an LREAL as an INT returns 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 or pyads.testserver in 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.

Get in touch
Home
Blog
Tools
Code
Contact
LinkedIn
Request Résumé