Raw services & errors¶
The raw service interface¶
When the high-level call does not expose a parameter you need, drop to the
service layer. Every method takes a request object, returns the full response
object, and does no interpretation whatsoever — checking
responseHeader.serviceResult and the per-operation results is your job.
| Service set | Methods |
|---|---|
| Discovery | serviceFindServers, serviceFindServersOnNetwork, serviceGetEndpoints |
| NodeManagement | serviceAddNodes, serviceDeleteNodes, serviceAddReferences, serviceDeleteReferences |
| View | serviceBrowse, serviceBrowseNext, serviceTranslateBrowsePathsToNodeIds, serviceRegisterNodes, serviceUnregisterNodes |
| Attribute | serviceRead, serviceWrite, serviceHistoryRead, serviceHistoryUpdate |
| Method | serviceCall |
A raw read with both timestamps, for example:
from o6.ns import ns0
rvi = ns0.datatypes.ReadValueId()
rvi.nodeId = o6.NodeId("ns=1;s=IntegerVariable")
rvi.attributeId = o6.AttributeId.VALUE
request = ns0.datatypes.ReadRequest()
request.nodesToRead = [rvi]
request.timestampsToReturn = ns0.datatypes.TimestampsToReturn.BOTH
response = client.serviceRead(request)
response.responseHeader.serviceResult.check()
dv = response.results[0]
print(dv.value, dv.sourceTimestamp, dv.serverTimestamp)
RegisterNodes is a service with no high-level equivalent and is worth knowing:
it asks the server for optimized NodeIds for nodes you will access repeatedly,
which can be a large win in polling loops.
request = ns0.datatypes.RegisterNodesRequest()
request.nodesToRegister = [o6.NodeId("ns=1;s=IntegerVariable")]
registered = client.serviceRegisterNodes(request).registeredNodeIds
# ... use registered[0] for reads and writes ...
release = ns0.datatypes.UnregisterNodesRequest()
release.nodesToUnregister = list(registered)
client.serviceUnregisterNodes(release)
Two service sets are deliberately absent from this list. The subscription and
monitored-item services are driven by Subscription and MonitoredItem,
which also own the Publish loop and the callback dispatch — going around them
would leave the client's bookkeeping inconsistent, so use those objects instead.
The Query service set is not exposed.
Like every other method, the serviceX calls block in synchronous code and are
awaitable in async code. The
Low-level service calls tutorial
works through more examples.
Errors and status codes¶
Three kinds of failure show up, and they are reported differently on purpose.
Bad status codes are OPC UA's normal way of saying "this operation did not
work". o6.StatusCode is an IntFlag with the full symbolic table, so it
supports bitwise tests, comparison, and int():
if o6.StatusCode.BAD in status:
...
status.check() # raises o6.StatusCodeError unless GOOD
status.check(message="reading the setpoint") # adds a note to the exception
Where a status surfaces as an exception, it is o6.StatusCodeError, which
carries code and symbol. Value-only reads raise it for scalar and list
targets; writes return statuses; valueOnly=False reads retain per-target statuses on the DataValue.
Service and operation failures preserve the original OPC UA code in
StatusCodeError.code. This includes node creation, history services, and both
client[nodeId] and server[nodeId]; missing nodes have code
BAD_NODE_ID_UNKNOWN. Scalar and batch failures use the same exception class;
batch indices and operation context appear in exception notes. APIs that return
statuses or raw results continue to return per-operation failures for inspection.
Malformed responses, such as an unexpected result count or missing lookup
attributes despite successful statuses, raise RuntimeError with operation
context.
Local usage errors use TypeError for unsupported argument types and
ValueError for invalid values or combinations, such as malformed ranges or
mismatched list lengths. Invalid client/subscription lifecycle operations raise
RuntimeError. Child navigation retains its own lookup conventions: missing
paths raise KeyError, and missing or ambiguous children raise AttributeError.
browseInteractive() raises ImportError when curses is unavailable.
Client log output — reconnect attempts, dropped user token policies, ignored
index ranges — goes to the logger you passed to the constructor or to
client.config.logger, defaulting to the o6.client logger. Turning on
logging.DEBUG for it is the fastest way to see what the stack is doing during a
failed handshake.
Concurrency and backpressure¶
A client admits at most maxAsyncServiceCalls application service calls at
once. The call that exceeds the ceiling does not wait — it fails immediately
with BadTooManyOperations. The default is 32:
import asyncio
reads = [asyncio.create_task(client.read(node_id)) for _ in range(33)]
results = await asyncio.gather(*reads, return_exceptions=True)
# 32 values, and one StatusCodeError(BAD_TOO_MANY_OPERATIONS)
The ceiling counts only application calls. Publish requests and internal
connection maintenance are not counted, so subscriptions do not consume the
budget. Raise it, or set 0 to remove the limit entirely:
client = Client(url)
client.config.maxAsyncServiceCalls = 512 # or 0 for unlimited
await client.connect()
Like the rest of client.config, this must be set before connect().
Removing the limit does not make deep concurrency free — it moves the failure
somewhere less obvious. Every open call holds its encoded request in memory,
and enough of them will run into the transport limits
(sendBufferSize, localMaxChunkCount) or into the server's own session and
per-request limits, which surface as service faults or a dropped channel.
If what you want is wait for a slot rather than fail fast, bound the
concurrency in Python rather than removing the ceiling. An asyncio.Semaphore
gives you that, and unlike a blocking limit it stays cancellable and composes
with asyncio.timeout():
sem = asyncio.Semaphore(256)
async def read(node_id):
async with sem:
return await client.read(node_id)
Keep the semaphore bound at or below maxAsyncServiceCalls and the client will
never reject a call for exceeding the ceiling.