This is a C++23 port of the php-opcua/opcua-cli v4.4.x command surface,
ported from the upstream documentation at
https://www.php-opcua.com/documentation/opcua-cli/v4.4.x.
It uses the open62541pp C++ OPC UA client wrapper from vcpkg.
The default preset uses the sibling vcpkg checkout at ../vcpkg and vcpkg
manifest mode to install open62541pp plus required host tooling:
cmake --preset vcpkg
cmake --build --preset vcpkgOn Windows with Visual Studio 2022 and a sibling vcpkg checkout:
cmake --preset vcpkg-windows
cmake --build --preset vcpkg-windows --config DebugIf your vcpkg checkout lives elsewhere, pass
-DCMAKE_TOOLCHAIN_FILE=/path/to/vcpkg/scripts/buildsystems/vcpkg.cmake.
GitHub Actions builds release archives for Linux x64, macOS arm64, and Windows x64 when a version tag is pushed:
git tag v0.1.0
git push origin v0.1.0The workflow publishes a GitHub Release with packaged opcua-cli binaries.
opcua-cli browse opc.tcp://localhost:4840 [/Objects|NODEID] [--recursive] [--depth=N] [--json]
opcua-cli read opc.tcp://localhost:4840 NODEID [--attribute=Value] [--json]
[--span-context=TRACEID:SPANID] [--traceparent=VALUE] [--dump-header]
opcua-cli write opc.tcp://localhost:4840 NODEID VALUE [--type=Int32] [--json]
opcua-cli endpoints opc.tcp://localhost:4840 [--json]
opcua-cli watch opc.tcp://localhost:4840 NODEID [--interval=250] [--duration=SECONDS] [--count=N] [--json]
opcua-cli events opc.tcp://localhost:4840 [NODEID] [--duration=SECONDS] [--count=N] [--select=FIELD,...] [--json]
opcua-cli generate:nodeset path/to/NodeSet2.xml [--output=generated] [--namespace=Generated::OpcUa]
opcua-cli dump:nodeset opc.tcp://localhost:4840 --output=Server.NodeSet2.xml [--namespace=N] [--root=NODEID] [--max-nodes=N] [--values]read's trace-context flags attach an OPC UA Part 26 §5.6.4 span context
to the request, in RequestHeader.additionalHeader. They exist to test what a
server does with one: open62541 ships no Part 26 DataTypes, so SpanContextDataType
is encoded here by hand from the Part 6 rules, and a server that reads it back
correctly has been shown to interoperate with an implementation that shares no
code with it. Passing any of them switches read from the high-level service
call to a raw request, because only the raw path can reach the header at all.
--span-context=TRACEID:SPANID— the Part 26 entry.TRACEIDis 32 hex digits (dashes optional) read as the Guid's canonical 8-4-4-4-12 text form,SPANIDis 16 hex digits read as a big-endianUInt64. Either side may be left empty to send the all-zero value for it.--traceparent=VALUE— the W3C entry, sent verbatim. Legal alongside the Part 26 entry: Part 4 §7.33 makes the slot a list, and a peer must ignore keys it does not understand.--dump-header— print the encodedadditionalHeaderExtensionObject as hex on stderr, envelope included, so what went out can be diffed against a capture or another implementation's encoding.
The Guid mapping is the part worth being careful about, and the reason the flag takes text rather than bytes. An OPC UA Guid is not an opaque 16-byte array: Part 3 §8.14 gives it four fields and Part 6 §5.2.2.6 writes the first three little endian. Reading the same 32 hex digits as raw bytes transposes the leading eight — and a server accepts that happily, with a Good status, correlating the request against the wrong trace. Part 26 v1.05.07 does not specify which reading is intended, so the two ends have to agree out of band.
# a known span context, with the bytes printed
opcua-cli read opc.tcp://localhost:4840 i=2255 \
--span-context=4bf92f35-77b3-4da6-a3ce-929d0e0e4736:00f067aa0ba902b7 --dump-header
# both keys at once
opcua-cli read opc.tcp://localhost:4840 i=2255 \
--span-context=4bf92f35-77b3-4da6-a3ce-929d0e0e4736:00f067aa0ba902b7 \
--traceparent=00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01watch polls a node's Value and prints one sample per line, flushing each
tick. It runs until interrupted unless you bound it: --duration=SECONDS
stops after that much wall-clock time, --count=N after N samples, and with
both it stops at whichever comes first. --interval is the gap between
samples in milliseconds (default 1000). None of these are --timeout, which
is the connect/request timeout and is unrelated.
# ten samples, quarter-second apart
opcua-cli watch opc.tcp://localhost:4840 ns=2;i=41 --interval=250 --count=10
# whatever arrives in the next 30 seconds, as JSON lines
opcua-cli watch opc.tcp://localhost:4840 ns=2;i=41 --duration=30 --jsonevents subscribes to a node's events rather than to a value: it creates a
subscription and a monitored item on the EventNotifier attribute with an
EventFilter, and prints one line per event, flushed per event. NODEID
defaults to i=2253, the Server object, which every server exposes as an event
notifier. --duration and --count bound the run exactly as they do for
watch.
The default select clauses are the BaseEventType fields every conforming
server defines — EventId, EventType, SourceNode, SourceName, Time,
ReceiveTime, Message, Severity
(OPC UA Part 5 §6.4.2).
--select replaces that list. A field is a browse path below BaseEventType:
Message, a namespace-qualified 2:VendorCode, or a nested 2:Vendor/Code.
# the next five events on the Server object
opcua-cli events opc.tcp://localhost:4840 --count=5
# just the identity fields, for 30 seconds, as JSON lines
opcua-cli events opc.tcp://localhost:4840 i=2253 --duration=30 --select=EventId,SourceNode,Time --jsonHuman output is space-separated Name=Value pairs in the order requested; a
value containing whitespace or a quote is quoted and escaped so the pairs stay
separable. Under --json each event is one object, {"Seq":N,"Fields":{…}},
with typed field values.
$ opcua-cli events opc.tcp://localhost:4840 --count=1
EventId=43db88e4d97e57bf53220cac05523184 EventType=i=2041 SourceNode=i=2253 SourceName=Server Time=2026-08-01T16:37:54Z ReceiveTime=2026-08-01T16:37:54Z Message="test event" Severity=500
Everything about the subscription itself — the confirmation line, a node that
reports no EventNotifier, select clauses the server accepted the item with but
answered Bad for — goes to stderr, so stdout stays a clean stream of events.
A rejected subscription is an error with a Hint naming the cause, and exits
nonzero:
$ opcua-cli events opc.tcp://localhost:4840 i=85
error: Event subscription on i=85 rejected: BadNotSupported (0x803D0000)
Hint: this node has no EventNotifier attribute, so it raises no events — the server rejected the request, the connection is fine.
Every server exposes the Server object i=2253 as an event notifier; try that, or browse for a node whose EventNotifier has the SubscribeToEvents bit set.
dump:nodeset crawls the server's address space breadth-first from --root
(default i=84, the Root node), following forward references, and writes a
UANodeSet XML with each node's class, browse name, display name, description,
per-class attributes, and forward references, plus the server's namespace URIs.
--namespace=N restricts the emitted nodes to namespace index N (the crawl
still traverses other namespaces to reach them); --max-nodes bounds the
crawl. Node values are not read by default because reading device-backed
variables can block on device I/O — pass --values to include each variable's
current value as a comment.
Global security and output flags mirror the PHP CLI where open62541pp exposes
the feature through its stock client configuration:
--security-policy=POLICY|-s POLICY
--security-mode=MODE|-m MODE
--cert=PATH
--key=PATH
--ca=PATH
--username=USER|-u USER
--password=PASS|-p PASS
--timeout=SECONDS|-t SECONDS
--json|-j
--debug|-d
--debug-stderr
--debug-file=PATH
--help|-h
--version|-vCertificate loading and advanced security policy selection are represented in the
CLI contract and validated, but require an open62541pp/open62541 build with
encryption plugins enabled.
Only opc.tcp:// works. opc.wss://, opc.ws://, wss:// and https://
endpoints are rejected up front with an explanatory error rather than a bare
BadTcpEndpointUrlInvalid. This is a hard limitation, not an oversight:
open625411.4 ships connection managers for POSIX TCP, UDP, Ethernet and MQTT only — there is no WebSocket transport to configure. (The 1.3-era libwebsockets support was server-side and was dropped in 1.4.)open62541ppis a thin wrapper and adds no transport of its own.- The client also filters discovered endpoints on
transportProfileUri == …/uatcp-uasc-uabinary, so a WebSocket endpoint would be rejected during endpoint selection even if a transport existed. - This vcpkg build has no TLS backend at all (
UA_ENABLE_ENCRYPTION_MBEDTLS,_OPENSSLand_LIBRESSLare all off), so there is nothing to layer TLS on.
For a server whose WebSocket endpoint carries UA Binary (subprotocol
opcua+uacp), a byte-level WSS↔TCP shim would be enough, because the framed
payload is the same UACP byte stream open62541 already speaks. That is not what
our deployments serve: the SCADA server's opc.wss:// endpoint negotiates
opcua+uajson only and rejects opcua+uacp outright, because it is aimed at
the browser client. UA-JSON is a different wire encoding, and that endpoint
deliberately implements neither UA discovery (GetEndpoints, FindServers)
nor UA SecureChannel. Supporting it would mean writing a second OPC UA client
stack — TLS, WebSocket, UA-JSON envelopes and session establishment — beside
open62541, not adding a flag to this one.
Until then, reach the server's opc.tcp:// endpoint directly. When it is not
routable — for instance when only an HTTPS reverse-proxy path is published —
tunnel it:
ssh -L 4840:localhost:4840 HOST
opcua-cli read opc.tcp://localhost:4840 i=2255-
Array values are rendered element by element.
readprints aType[count]header and then one indexed line per element, because for the arrays worth reading —i=2255NamespaceArray above all — element N is index N:$ opcua-cli read opc.tcp://localhost:4840 i=2255 Value: String[3] [0] http://opcfoundation.org/UA/ [1] urn:host:Telecontrol:Server [2] http://telecontrol.ru/opcua/filesystem/FileTypeUnder
--jsonthe same value is a real JSON array with typed elements (numbers stay numbers), plus aCountfield — never the string<array>.watchkeeps one line per sample and renders arrays inline as[a, b, c]. A typed empty array reads asString[0]/[], distinct from anullvalue. Floating-point values use the shortest round-trip form, so a1e-09reading is reported as1e-09and not rounded to0.000000. -
NodeIds are printed in canonical form (
i=2253,ns=2;i=1001) in every command and in--json, so they can be pasted straight into another invocation. -
ByteStrings — an
EventIdabove all — are rendered as lowercase hex, cut at 64 bytes with the true length appended. A bad one stays visible, in all three of its shapes. A field the server sent with no value at all reads<null>(JSONnull), a zero-length ByteString reads<empty>, and an all-zero one renders as its true hex (0000000000000000) — never blanked, normalised or abbreviated. OPC UA Part 5 §6.4.2 makesEventIdmandatory on every event, so any of the three is a server or proxy defect, andeventsexists in large part to make them observable.The third shape is the one that matters in practice and the easiest to miss. A phantom event reassembled from a payload-less notification carries
EncodeEventIdByteString(0): eight zero bytes — a well-formed ByteString of the correct length, neither null nor empty. A check written only againstnullwould wave it through as a valid id. Screen for all three:opcua-cli events … --json | jq 'select(.Fields.EventId == null or .Fields.EventId == "<empty>" or (.Fields.EventId | test("^0+$")))'
-
readexplains status codes that look like tool failures but are the server's correct answer.--attribute=Valueon an Object node returnsBadAttributeIdInvalid; the CLI adds aHintline naming the node class and pointing at--attribute=DisplayNameorbrowse. -
stdout carries only command output (including
--json); client logs go to stderr and show warnings and errors only. Each of--debug,--debug-stderrand--debug-file=PATHenables full logging on its own and picks where it goes — stdout, stderr, or a file respectively. They are destinations, not modifiers: none of them needs to be combined with another to take effect. -
writeexits with code 1 when the server answers with a Bad status, so scripts can detect rejected writes without parsing output. -
browsedoes the same, and never reports a server error as an empty node. A refused browse prints the status and a hint on stderr, leaves stdout empty, and exits 1; under--jsonit emits{"nodes": [...], "Status": ..., "Hint": ...}instead of the bare array. In a--recursivecrawl a node whose own children could not be listed is marked!! browse failed: STATUSin the tree and carrieschildStatusin JSON, so a refusal is never mistaken for a leaf:$ opcua-cli browse opc.tcp://localhost:4840 'ns=99;i=424242' error: browse failed: BadNodeIdUnknown (0x80340000) hint: The server has no node with this NodeId. Check the namespace index — ... -
Status codes missing from open62541's name table (vendor-specific server codes) are reported by severity, e.g.
Bad (vendor-specific) (0x80300000).
MIT. See LICENSE.