Machine DataPythonPython 3.8+ · asyncuaMIT licenseIntermediate
Subscribe to OPC UA data changes and log them to CSV (Python)
The OPC UA machine reader already in this library reads a snapshot once. Most real monitoring wants the other half of OPC UA's story: subscribe to a node and get pushed updates the instant the value changes, instead of polling on a timer. This script does that — subscribe, log every change to CSV with a timestamp, unsubscribe when the duration's up. Tested end to end against a local OPC UA test server, watching a variable that changes once a second, before this ever needs real hardware.
Before you run it
pip install asyncua- An OPC UA server endpoint URL and the NodeId of the variable to watch — browse for it first with the OPC UA machine reader if you don't already know it
The code
"""Subscribe to an OPC UA node's data changes and log them to CSV (asyncua).
Usage: python opcua_subscription_logger.py opc.tcp://192.168.0.20:4840 "ns=2;i=2" out.csv 30
"""
import asyncio
import csv
import sys
from datetime import datetime, timezone
from asyncua import Client
PUBLISHING_INTERVAL_MS = 500
class ChangeLogger:
"""asyncua calls datachange_notification() on the event loop for every update."""
def __init__(self, writer):
self.writer = writer
def datachange_notification(self, node, value, data):
timestamp = datetime.now(timezone.utc).isoformat()
print(f"{timestamp} {node} {value}")
self.writer.writerow([timestamp, str(node), value])
async def run(url, node_id, csv_path, seconds):
async with Client(url=url) as client:
node = client.get_node(node_id)
with open(csv_path, "w", newline="") as fh:
writer = csv.writer(fh)
writer.writerow(["timestamp", "node", "value"])
handler = ChangeLogger(writer)
sub = await client.create_subscription(PUBLISHING_INTERVAL_MS, handler)
sub_handle = await sub.subscribe_data_change(node)
print(f"Subscribed to {node_id} - logging for {seconds}s")
await asyncio.sleep(seconds)
await sub.unsubscribe(sub_handle)
await sub.delete()
def main():
if len(sys.argv) < 4:
sys.exit(
"Usage: python opcua_subscription_logger.py "
"<endpoint-url> <node-id> <csv-path> [seconds]"
)
url, node_id, csv_path = sys.argv[1:4]
seconds = int(sys.argv[4]) if len(sys.argv) > 4 else 30
asyncio.run(run(url, node_id, csv_path, seconds))
if __name__ == "__main__":
main()What you get
$ python opcua_subscription_logger.py opc.tcp://192.168.0.20:4840 "ns=2;i=2" spindle_log.csv 5
Subscribed to ns=2;i=2 - logging for 5s
2026-07-13T14:02:11.264581+00:00 ns=2;i=2 4120.0
2026-07-13T14:02:11.775183+00:00 ns=2;i=2 4130.0
2026-07-13T14:02:12.772859+00:00 ns=2;i=2 4140.0
2026-07-13T14:02:13.765920+00:00 ns=2;i=2 4150.0
2026-07-13T14:02:14.761937+00:00 ns=2;i=2 4160.0
spindle_log.csv
timestamp,node,value
2026-07-13T14:02:11.264581+00:00,ns=2;i=2,4120.0
2026-07-13T14:02:11.775183+00:00,ns=2;i=2,4130.0
2026-07-13T14:02:12.772859+00:00,ns=2;i=2,4140.0
2026-07-13T14:02:13.765920+00:00,ns=2;i=2,4150.0
2026-07-13T14:02:14.761937+00:00,ns=2;i=2,4160.0How it works
create_subscription(interval_ms, handler)sets the publishing interval — how often the server is allowed to batch and send updates — not a polling loop on the client side; the server pushes, this script just waits.datachange_notification(node, value, data)on the handler object is the contractasyncuacalls into — implement that one method and the subscription mechanics (acknowledgements, keep-alives) are handled for you.- Passing the CSV
writerintoChangeLogger.__init__means every notification writes straight to disk as it arrives — nothing is buffered in memory waiting for the script to exit, so a crash mid-run still leaves a usable partial file. sub.unsubscribe()thensub.delete()is the clean two-step teardown — skipping it still works for a short script that's about to exit anyway, but a long-running collector that creates and drops subscriptions repeatedly will leak them on the server without it.
Gotchas & honest limits
- Same security caveat as the OPC UA reader: this connects with no security policy, which many production servers reject outright. Add a certificate and a signed/encrypted policy for anything beyond a lab test.
- The publishing interval is a maximum rate, not a guarantee — a variable that never changes never fires a notification, and one that changes faster than the interval gets coalesced, not queued.
ChangeLoggeris intentionally synchronous —asyncuaalso supportsasync def datachange_notification(...)if the handler itself needs to await something (writing to a database, calling another service); don't block the event loop with slow synchronous work in a busier version of this script.- This script watches one node. Watching many means either one subscription with multiple
subscribe_data_change()calls (cheaper) or multiple subscriptions (more isolation) — the tradeoff matters once you're past a handful of variables.
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.