Skip to content

Reading & Writing

You're building a small control loop against the distilling example server: discover the writables, peek at the current state, then change it. The first half of that loop — see what the still is doing and tell it to do something different — lives on this page. The next page, Call a method, flips the on-switch; the one after that, Monitor data changes, is where you stop polling and let the server push values to you.

Once you know which NodeIds a server exposes — from browsing — the next step is reading their current values and, where the server allows it, writing new ones. Reads accept one node or a list; writes also accept a mapping. Both can target any attribute. Value-only reads raise on failure; raw reads retain per-target statuses in DataValues, and writes return statuses.

This page walks through read and write primitives:

  • Read one or several variables.
  • Write one or several variables.
  • Read and write non-Value attributes.

Info

This tutorial expects the example server running in the background, and assumes you know how to create and connect a client and how to browse the address space. The NodeIds used below are the ones exposed by the distilling example server's DistillingSystem object at ns=1;i=1000.


Read one or several variables

client.read(target) is the basic read service call: hand it a NodeId and get back the current value of its Value attribute. The simplest read takes one NodeId and returns its current value.

Let's look into the Kettle at ns=1;i=1300 — its Level, Temperature and WashStart:

Level = client.read("ns=1;i=1301")
Temperature = client.read("ns=1;i=1302")
WashStart = client.read("ns=1;i=1303")

To read several variables in one round-trip, pass a list of NodeIds. The result is a list in the same order as the input:

values = client.read([
        "ns=1;i=1301",
        "ns=1;i=1302",
        "ns=1;i=1303",
    ])
print(values)

The values of the distillig server will be different on every read — Level climbs as the kettle fills, Temperature rises during heating, and WashStart records the kettle level at the moment the still started producing spirit.

If a node does not exist or the server rejects a value-only read, scalar and list forms both raise o6.StatusCodeError. Its .code preserves the OPC UA status. A list failure includes the failed index in an exception note.

from o6 import Client, StatusCodeError

with Client("opc.tcp://localhost:4840") as client:
    try:
        values = client.read(["ns=1;i=1301", "ns=1;i=1302"])
    except StatusCodeError as e:
        print("read failed:", e.code, getattr(e, "__notes__", []))

Use valueOnly=False to receive a DataValue for every target, including failed ones. timestampsToReturn selects SOURCE (the default), SERVER, BOTH, or NEITHER from ns0.datatypes.TimestampsToReturn. Service failures still raise StatusCodeError. The same read options and result shapes work with server.read; local access uses administrative privileges.

Tip

Any string form accepted by o6.NodeId(...) works here — i=, s=, ns=...;i=..., nsu=...;i=..., even the shortname URI form once the matching nodeset is loaded. See NodeIds and namespace info for the full syntax.

Putting it all together

import o6
from o6 import Client

with Client("opc.tcp://localhost:4840") as client:
    # One read per round-trip
    level = client.read("ns=1;i=1301")
    temperature = client.read("ns=1;i=1302")

    # Or batch them in a single round-trip
    kettle = client.read([
        "ns=1;i=1301",
        "ns=1;i=1302",
        "ns=1;i=1303",
    ])
    print(kettle)

    print("level value:", level, "  is o6.Float?", isinstance(level, o6.Float))

Write one or several variables

client.write(target, value) is the basic write service call: hand it a NodeId and a new value, and the server replaces the Value attribute. In the distilling example the only writable variables on the server are Status.Operating (ns=1;i=1203) and Status.Setpoint (ns=1;i=1204) — everything under Kettle, Distillate, Actuators and Events is read-only. You can see that directly from AccessLevel:

import o6
from o6 import AttributeId, Client
from o6.ns.ns0.datatypes import NodeClass

with Client("opc.tcp://localhost:4840") as client:
    writable = client.browse(
        "ns=1;i=1000",
        nodeClassMask=NodeClass.VARIABLE,
    )
    for ref in writable:
        access = client.read(ref.nodeId, attr=AttributeId.ACCESS_LEVEL)
        # bit 1 of AccessLevel = "writable"
        if access & 2:
            print(ref.browseName.name, "→", o6.NodeId(f"nsu={ref.nodeId.ns.uri};{ref.nodeId.id}"))

To pause the still, write False to Operating:

client.write("ns=1;i=1203", False)

To write several variables in one round-trip, pass a list of NodeIds and a parallel list of values (same order, same length). The result is a list of StatusCodes, one per write:

statuses = client.write(
        ["ns=1;i=1203", "ns=1;i=1204"],
        [True, 90.0],
    )

A StatusCode other than Good on any entry means that particular write failed — the other writes in the same call still happened, and client.write does not raise.

Info

If you'd rather treat writes as key/value pairs, client.write({"ns=1;i=1204": 90.0}) is accepted as well — useful when the targets are computed dynamically and live in a dict.

Putting it all together

from o6 import Client

with Client("opc.tcp://localhost:4840") as client:
    # Single write
    client.write("ns=1;i=1203", False)

    # Or batch them in a single round-trip
    statuses = client.write(
        ["ns=1;i=1203", "ns=1;i=1204"],
        [True, 90.0],
    )
    print(statuses)

Read and write non-Value attributes

By default both read and write operate on the Value attribute of a variable. The attr= keyword lets you target a different attribute — BrowseName, DisplayName, NodeClass, Description, DataType, ValueRank, AccessLevel, and so on.

Read a non-Value attribute

Let's inspect the Setpoint variable's metadata without reading its value:

name = client.read("ns=1;i=1204", attr="BrowseName")
display = client.read("ns=1;i=1204", attr="DisplayName")
klass = client.read("ns=1;i=1204", attr=AttributeId.NODE_CLASS)
access = client.read("ns=1;i=1204", attr=AttributeId.ACCESS_LEVEL)

# `name` is a QualifiedName; `.name` gives just the bare browse name.
print(name.name)        # "Setpoint"
print(display.text)     # LocalizedText.text — the bare string
print(int(klass))       # raw integer (the wire value, 2 == VARIABLE)
print(access)           # raw integer

The attr= value can be either a string (case-insensitive, ignores punctuation — "BrowseName", "browse_name", "BROWSENAME" all match) or an o6.AttributeId enum member if you want exactness.

Write a non-Value attribute

Writing a non-Value attribute uses both value= and attr=. Attributes that are themselves structured types — Description is a LocalizedText — must be wrapped in the matching type:

from o6 import Client, LocalizedText, QualifiedName, StatusCodeError

with Client("opc.tcp://localhost:4840") as client:
    try:
        status = client.write(
            "ns=1;i=1204",
            value=LocalizedText("Target kettle temperature"),
            attr="Description",
        )
    except StatusCodeError as e:
        # The distillery server denies writes to non-Value attributes
        # (BAD_USER_ACCESS_DENIED); other servers may allow them.
        # The call shape is correct either way — it's the server's
        # AccessControl that decides.
        status = e.code
    print("description write:", status)

    try:
        client.write(
            "ns=1;i=1204",
            value=QualifiedName("Setpoint"),
            attr="BrowseName",
        )
    except StatusCodeError as e:
        # Same caveat: server-side access control may refuse.
        print("browsename write refused:", e.code)

Putting it all together

import o6
from o6 import AttributeId, Client

with Client("opc.tcp://localhost:4840") as client:
    # Read a structured attribute (QualifiedName)
    name = client.read("ns=1;i=1204", attr="BrowseName")
    print(name.name)        # "Setpoint"  — QualifiedName.name strips the namespace

    # Read a scalar attribute (NodeClass enum -> raw int on the low-level read path)
    klass = client.read("ns=1;i=1204", attr=AttributeId.NODE_CLASS)
    print(int(klass))       # 2 — VARIABLE

    # AccessLevel: bit 1 set means writable
    access = client.read("ns=1;i=1204", attr=AttributeId.ACCESS_LEVEL)
    print("writable:", bool(access & 2))

    # The distillery server only allows `Value` writes; non-Value
    # attribute writes return BAD_USER_ACCESS_DENIED. The call
    # shape is correct — switch to a Value write (no `attr=`) for
    # an example that actually succeeds end-to-end:
    status = client.write("ns=1;i=1204", 90.0)   # writes the Value attribute
    print("setpoint write:", status)             # GOOD

What's next?

  • Call a method — same shape of call, but for methods on objects.
  • Low-level service calls — drop down to the raw Read / Write service when you need full control over the request.