Lifecycle¶
Creating a client¶
A client is created before it knows anything about a connection. The
constructor takes the settings most applications need as keyword arguments;
everything else is set on client.config afterwards.
The full constructor signature is:
Client(
endpointUrl="", # server to connect to
loop=None, # asyncio event loop to use
*,
logger=None, # Python logger for all client output
certificate=None, # path, Path, or DER/PEM bytes
privateKey=None, # path, Path, or DER/PEM bytes
trustList=None, # list of trusted server certificates
revocationList=None, # list of CRLs
securityMode=None, # o6.SecurityMode
securityPolicy=None, # o6.SecurityPolicy or short name
applicationUri=None, # URI sent in the ApplicationDescription
username=None, # UserNameIdentityToken
password=None,
allowNonePolicyPassword=False, # explicit opt-in for plaintext password tokens
name="", # process-unique client name
)
Info
Nothing in that list is mandatory. Client() with no arguments is valid — the
endpoint URL can be supplied later through client.config.endpointUrl (just note that connect will error, when no valid endpoint url is configured).
The client name¶
Every client carries a name. When you do not pass one, the client is named
client1, client2, … in construction order. The name is not cosmetic: it
becomes the scope under which the server's namespaces are registered in the
process-wide namespace table (see Namespaces and NodeIds),
which is what lets several clients talk to servers that use the same namespace
URIs without colliding.
A name must be a valid Python identifier, must not look like server<digits>,
must not be global or ::global, and must be unique in the process. Violations
raise ValueError immediately at construction:
Client(url, name="plant-hmi") # ValueError: not an identifier
Client(url, name="server1") # ValueError: reserved pattern
Client(url, name="hmi") # fine, once
Client(url, name="hmi") # ValueError: not unique
Everything else goes on client.config¶
client.config is the live ClientConfig behind the native client. Assign to
it any time before connect():
client = Client("opc.tcp://localhost:4840")
client.config.sessionName = "recipe-controller"
client.config.requestedSessionTimeout = 60_000 # ms
client.config.sessionLocaleIds = ["en-US", "de-DE"]
client.config.timeout = 5_000 # per-request timeout, ms
client.connect()
The settings that matter most often:
| Property | Meaning |
|---|---|
endpointUrl |
Server URL. Required before any connect. |
endpoint |
A complete EndpointDescription, usually taken from getEndpoints(), to pin the exact endpoint instead of letting the client select one. |
securityMode |
o6.SecurityMode.NONE / SIGN / SIGN_AND_ENCRYPT. |
securityPolicy |
Short name ("Basic256Sha256") or o6.SecurityPolicy member. securityPolicyUri holds the full URI. |
certificate, privateKey |
Client identity. Accepts a path string, a Path, or raw DER/PEM bytes. |
trustList, revocationList |
Certificates the client trusts, and their CRLs. |
applicationUri |
URI advertised in the ApplicationDescription. Must match the URI inside the client certificate on secured channels. |
applicationDescription |
The whole description, if you want to shape it in detail. |
sessionName, requestedSessionTimeout, sessionLocaleIds |
Session identity, lifetime, and preferred locales. |
timeout, secureChannelLifeTime, connectivityCheckInterval |
Request timeout, channel renewal interval, and keepalive period, all in ms. |
sendBufferSize, recvBufferSize, localMaxMessageSize, localMaxChunkCount |
Transport limits. Relevant when moving large arrays; see Performance. |
outstandingPublishRequests |
How many Publish requests are kept in flight for subscriptions. |
maxAsyncServiceCalls |
Ceiling on concurrent service calls; the call that exceeds it fails with BadTooManyOperations. Defaults to 32; 0 disables the limit. See Too many concurrent calls. |
logger |
Write-only. Redirects client log output to a Python logger. |
noReconnect, noNewSession |
Opt out of automatic reconnection or of creating a replacement session. |
allowNonePolicyPassword |
Permit an unencrypted username/password token. Defaults to False, even with credentials. |
Credentials are set either through the constructor or through the config:
Passing username= to the constructor does the same thing. Credentials never
enable allowNonePolicyPassword implicitly: the default is False. If the
server only offers password authentication without encryption, connect()
fails instead of weakening that policy or falling back to anonymous access.
Use an encrypted channel as shown below. If you deliberately need plaintext
password tokens for a development endpoint, pass allowNonePolicyPassword=True
to Client(...), or set client.config.allowNonePolicyPassword = True before
connecting. This is an explicit opt-in to transmitting the password without
encryption; the server must also allow that token policy. Existing applications
that relied on the former implicit opt-in must configure encryption or opt in.
For certificate-based user authentication (as opposed to the channel
certificate) use client.config.setAuthenticationCert(cert, key).
Certificates can be generated on the spot when you don't have a PKI at hand:
from o6.util import createSelfSignedCertificate
key, cert = createSelfSignedCertificate(
appUri="urn:example:client",
commonName="ExampleClient@localhost",
)
A fully secured client therefore looks like this:
from pathlib import Path
from o6 import Client, SecurityMode, SecurityPolicy
client = Client(
"opc.tcp://localhost:4840",
certificate=Path("client_cert.der"),
privateKey=Path("client_key.der"),
trustList=[Path("server_cert.der")],
securityMode=SecurityMode.SIGN_AND_ENCRYPT,
securityPolicy=SecurityPolicy.BASIC256SHA256,
applicationUri="urn:example:client",
username="operator",
password="s3cr3t",
)
client.connect()
The Security tutorial walks through the handshake step by step, including what each failure mode looks like, and Application Description covers how the client identifies itself.
Connecting and disconnecting¶
connect() takes the URL configured in teh client.config:
client = Client("opc.tcp://localhost:4840")
client.connect()
client = Client()
client.config.endpointUrl = "opc.tcp://localhost:4840"
client.connect()
A plain connect() does considerably more than opening a socket. In order, it:
- finalizes the encryption settings (certificate, key, trust list),
- opens the SecureChannel,
- creates and activates the Session,
- reads the server's
ApplicationUriand registers it as this client's namespace 1, - synchronizes all remaining server namespaces and their custom data types
(
updateRemoteNamespaces()), - creates the default subscription used by
monitor()unless opted out, - starts the background worker thread that drives the client's event loop.
For sessions that do not need monitoring, use:
This skips subscription creation, so subscription limits or permissions do not
prevent reads, writes, calls, or browsing. To monitor later, create a subscription
with createSubscription() and pass it as subscription= to monitor() or
monitorEvent(); defaultSubscription raises RuntimeError while no default exists.
Failed or cancelled post-session setup closes the activated session before the
error propagates, including when default-subscription creation is rejected.
Use createDefaultSubscription=False to connect without that subscription.
To open only the SecureChannel — enough for discovery, or as the first half of a
session transfer — pass noSession=True:
Disconnecting is the mirror image. By default it deletes all subscriptions, closes the Session, closes the SecureChannel, and stops the worker thread:
Two switches change that:
client.disconnect(deleteSubscriptions=False) # leave subscriptions on the server
client.disconnect(closeSession=False) # close only the channel, keep the session
With closeSession=False the Session stays alive on the server and
deleteSubscriptions is ignored — that combination is what session transfer and
channel renewal need. disconnect() is always safe to call: on an already
disconnected client, or one whose event loop is gone, it returns None instead
of raising.
A client can be reconnected after disconnecting. Connecting again re-runs the whole sequence above, which means a new default subscription with a new id.
Context managers¶
The idiomatic form is the context manager, which connects on entry (if not already connected) and disconnects on exit, including when the block raises:
from o6 import Client
with Client("opc.tcp://localhost:4840") as client:
value = client.read("ns=1;s=IntegerVariable")
The asynchronous counterpart behaves identically with await inside:
async with Client("opc.tcp://localhost:4840") as client:
value = await client.read("ns=1;s=IntegerVariable")
Inspecting the connection¶
client.connected is True only when the channel is open and the session is
activated:
To observe connection notifications from inside Python rather than
polling state, register a handler on client.onGlobalNotification —
see Notification handler for the worked
example.
client.state returns the full picture as a 3-tuple of
(SecureChannelState, SessionState, StatusCode):
channel, session, status = client.state
print(channel) # SecureChannelState.OPEN
print(session) # SessionState.ACTIVATED
print(status) # StatusCode.GOOD
After a disconnect the same tuple reads (CLOSED, CLOSED, BAD_CONNECTION_CLOSED).
There is no fine-grained state-change notification — onGlobalNotification only
sees the connection start and stop, not every intermediate
SecureChannelState/SessionState transition — so you still poll state
when you need the full picture, which is what the State callbacks
tutorial builds a supervision
loop around.
Operations on a client that is not connected raise immediately rather than silently doing nothing:
Discovery¶
Discovery answers two questions: which servers exist and what endpoints does
this server offer. Both are services, so they need an open SecureChannel — but
not a session. The usual shape is therefore a throwaway client with
noSession=True:
from o6 import Client
client = Client("opc.tcp://localhost:4840")
client.connect(noSession=True)
for ep in client.getEndpoints("opc.tcp://localhost:4840"):
print(ep.endpointUrl, ep.securityMode, ep.securityPolicyUri)
client.disconnect()
Note the endpoint URL appears twice, and for different reasons: the constructor
argument decides where the channel goes, the method argument is the URL
carried inside the request. Calling getEndpoints() on a client that has no
channel raises StatusCodeError(BadServerNotConnected).
Three calls are available:
getEndpoints(endpointUrl, *, localeIds=None, profileUris=None)returns the list ofEndpointDescriptionobjects: URL, security mode, security policy URI, transport profile, server certificate, and the acceptedUserTokenPolicyentries.profileUrisnarrows the result to a transport profile.findServers(endpointUrl, *, serverUris=None, localeIds=None)returnsApplicationDescriptionobjects. Against a Local Discovery Server this enumerates every registered server on the host; against a normal server it returns that server's own description, including the discovery URLs you can feed back intogetEndpoints().findServersOnNetwork(startingRecordId=0, maxRecordsToReturn=0, serverCapabilityFilter=None)queries an LDS for servers that announced themselves via mDNS or RegisterServer2. The result is paginated throughstartingRecordId; eachServerOnNetworkentry carries arecordIdto continue from. A regular server may return an empty list or report that the service is unsupported.
Discovery conveniences raise StatusCodeError with the original service status
when a request fails, including unsupported network discovery. An empty list
indicates a successful request with no matching entries. Raw discovery services
return the full response for inspection.
A discovered endpoint can be pinned for the real connection:
endpoints = probe.getEndpoints(url)
client = Client(url)
client.config.endpoint = endpoints[0]
client.connect()
Sessions and channels, beyond connect¶
Two scenarios need more control than connect() / disconnect().
Session transfer¶
An OPC UA Session is not tied to a SecureChannel for life; it can be re-bound to a new one. Both halves are available.
If this client owns the session and its channel was renewed or re-established, re-bind it:
If another client owns the session and you want to take it over, open a channel without a session and activate the existing one with its authentication token and server nonce:
Both calls send ActivateSession and then create the default subscription, so
the client is fully usable afterwards. On the handing-over side, close the
channel but keep the session alive with disconnect(closeSession=False).
Reverse connect¶
In a reverse-connect deployment the server initiates the TCP connection, which is how you reach a client that sits behind a firewall. The client opens a listen socket and waits:
client = Client("opc.tcp://localhost:4840") # still needed: the server's URL
client.startReverseConnect(port=4843, hostnames=["0.0.0.0"])
# returns once a server has connected — the session is activated
value = client.read("ns=1;s=Rev")
client.disconnect()
The call does not return until a server actually connects, so in synchronous code it blocks; in async code, schedule it as a task and await it with a timeout:
task = asyncio.ensure_future(client.startReverseConnect(4843))
await asyncio.wait_for(task, timeout=15)
print(client.connected) # True
hostnames selects which interfaces to advertise — None or an empty list lets
the stack decide. The endpoint URL still has to be configured, because the
client uses it to identify the server it expects. Tear the connection down with
the ordinary disconnect(). The server side of the handshake is
server.addReverseConnect(url) and server.removeReverseConnect(handle).
Lifecycle notes¶
A few practical details worth keeping in mind for long-running programs.
The client owns native resources and a thread. disconnect() releases the
session and channel and stops the worker; dropping the last reference triggers a
best-effort cleanup in __del__, but relying on the garbage collector for that
is fragile — prefer the context manager or an explicit disconnect().
Subscriptions are deleted on disconnect by default. If you intend to keep them
alive across a channel renewal — the point of disconnect(closeSession=False) —
pass deleteSubscriptions=False or keep the session open.
Node objects cache their children. That cache is only valid while the server's model does not change; after the server adds or renames nodes, resolve them again from the client rather than reusing a cached node.
Client names, and therefore namespace scopes, are never reused within a process. Creating and discarding thousands of clients in one process grows the namespace table, so in a service, reconnect an existing client instead of constructing new ones.