ualib — the C++ client library

ualib provides eso::uatools::Client, a single class that implements the protocol-agnostic ifw::fnd::defs::ICommAdapter contract (defined in ifw-fnd) over the open62541 OPC UA SDK.

The interface is intentionally OPC UA-free at the public surface: ICommAdapter is a generic adapter contract (read / write / browse / call / subscribe / lifecycle), and eso::uatools::Client is the OPC UA implementation of that contract. Code written against ICommAdapter can swap in any other adapter (Modbus, ZMQ, in-process mock) without source changes.

open62541 is vendored inside ualib and never appears in the public include path — PIMPL hides it. Downstream consumers see only ifw::fnd::defs types and eso::uatools::Client.

Linking

ualib ships as one shared library, libuatoolsUalib.so. It depends on ifw-fnd.defs (the contracts) and fmt (formatting). open62541 is a private dependency.

waf:

declare_cprogram(target='myapp',
                 use='ualib ifw-fnd.defs fmt')

CMake:

find_package(PkgConfig REQUIRED)
pkg_check_modules(UATOOLS REQUIRED uatools)
target_link_libraries(myapp PRIVATE uatoolsUalib)

The uatools.pc pkg-config file is installed to $PREFIX/lib64/pkgconfig/.

Includes

#include <eso/uatools/ualib/client.hpp>   // eso::uatools::Client
#include <ifw/fnd/defs/iCommAdapter.hpp>  // ICommAdapter contract + types
#include <ifw/fnd/defs/logger.hpp>        // InstallLogger / FND* macros

The ICommAdapter contract

The contract (full source in ifw-fnd/defs/src/include/ifw/fnd/defs/iCommAdapter.hpp) gives ualib both its shape and its expectations. Key types:

  • Status — protocol-agnostic outcome classification (Ok, Timeout, NotConnected, AlreadyConnected, InvalidState, BadRequest, NotFound, AccessDenied, TypeMismatch, Unsupported, Cancelled, RemoteError, TransportError, InternalError).

  • Value — universal payload: a std::variant of monostate, bool, int32 / uint32 / int64 / uint64, float, double, string, plus the corresponding vector types and a PropertyMap.

  • Result — base of every per-call result, carrying status and an explanatory message. operator bool returns true iff the status is Ok.

  • RequestOptions — per-call knobs: timeout, optional CancellationToken, PropertyMap for protocol-specific tuning.

  • ConnectionOptions — endpoint + auth + security PropertyMap.

The interface itself:

namespace ifw::fnd::defs {

    class ICommAdapter {
    public:
        virtual ~ICommAdapter() = default;

        // Capabilities — runtime feature negotiation
        virtual Capabilities GetCapabilities() const = 0;

        // Lifecycle
        virtual Result Configure(const ConnectionOptions&) = 0;
        virtual Result Connect()    = 0;
        virtual Result Disconnect() = 0;
        virtual bool   IsConnected() const = 0;
        virtual ConnectionStatus GetConnectionStatus() const = 0;
        virtual RegistrationToken
            SetConnectionStateHandler(ConnectionStateHandler) = 0;

        // Data plane
        virtual DiscoverResult Discover(const DiscoverRequest&) = 0;
        virtual BrowseResult   Browse  (const BrowseRequest&)   = 0;
        virtual ReadResult     Read    (const ReadRequest&)     = 0;
        virtual WriteResult    Write   (const WriteRequest&)    = 0;
        virtual CallResult     Call    (const CallRequest&)     = 0;

        // Pub/sub (per-node identity; no SubscriptionId in the public API)
        virtual SubscribeResult
            Subscribe(const std::vector<Address>& nodes,
                      SubscriptionHandler handler,
                      const SubscribeOpts& opts = {}) = 0;
        virtual UnsubscribeResult
            Unsubscribe(const std::vector<Address>& nodes) = 0;
        virtual Result UnsubscribeAll() = 0;
    };

}

ualib’s view of these

eso::uatools::Client reports the following capabilities:

c.read                    = true;
c.write                   = true;
c.browse                  = true;
c.call                    = true;
c.subscribe               = true;
c.batch                   = true;
c.connection_state_events = true;
c.cancellation            = true;
c.discover                = false;  // deferred (post-IFW26)

User authentication is supported through ConnectionOptions::properties["username"] / ["password"]. Anonymous if absent. Encryption (Basic256Sha256 + SignAndEncrypt) is deferred to post-IFW26.

Minimal usage

#include <eso/uatools/ualib/client.hpp>
#include <ifw/fnd/defs/iCommAdapter.hpp>
#include <ifw/fnd/defs/logger.hpp>

namespace fnd = ifw::fnd::defs;

int main() {
    ifw::fnd::InstallLogger(ifw::fnd::MakeStdoutLogger());

    eso::uatools::Client client;

    fnd::ConnectionOptions opts;
    opts.endpoint = "opc.tcp://127.0.0.1:4840";
    // Optional: opts.properties.Set("username", "user");
    //           opts.properties.Set("password", "pswd");
    if (auto r = client.Configure(opts); !r) return 2;
    if (auto r = client.Connect();        !r) return 2;

    // Read
    fnd::ReadRequest req;
    req.items.push_back({"ns=2;s=Temperature", {}});
    fnd::ReadResult res = client.Read(req);
    if (res) {
        for (const auto& item : res.items) {
            if (item.status == fnd::Status::Ok && item.data) {
                // item.data->value is a std::variant
            }
        }
    }

    client.Disconnect();
}

Write — explicit type and auto type

ualib’s Write is type-aware: the Value you submit must match the node’s OPC UA DataType. Two patterns:

Explicit: you set the right alternative of Value yourself.

fnd::WriteRequest req;
req.items.push_back({"ns=2;s=Enabled", fnd::Value{true}, {}});
client.Write(req);

Auto-detect (one round trip): call client.ReadDataType(node) first, then build the Value of the right alternative. UaClient’s two-arg write NODE VALUE form uses this pattern under the hood.

Browse

fnd::BrowseRequest req;
req.root           = "ns=2;s=system";   // optional; default = ns=0;i=85 (Objects)
req.recursive      = true;               // descend into subtrees
req.include_values = true;               // read each Variable's current value
fnd::BrowseResult res = client.Browse(req);

Call (OPC UA Method)

fnd::CallRequest req;
req.method = "ns=2;s=device.Init";
// input arguments, in declaration order:
// req.input_arguments.push_back({"x", fnd::Value{double{23.5}}});
fnd::CallResult res = client.Call(req);
// res.output_arguments holds the method's return values

The parent (Object) NodeId is derived from method by stripping the trailing dot-segment. For NodeIds where that heuristic doesn’t apply (numeric NodeIds, dot-free strings), pass it explicitly:

req.options.properties.Set("object", "ns=2;s=device");

Subscribe — per-node handler binding

Each Subscribe() call’s handler is bound to the nodes that call supplied. Re-subscribing a node replaces that node’s handler; other nodes’ bindings stay intact. Two consumers of the same Client can therefore have their own callbacks without stepping on each other:

client.Subscribe({"ns=2;s=Counter"}, on_counter_change);
client.Subscribe({"ns=2;s=Temp"},    on_temp_change);
// Counter still routes to on_counter_change; Temp goes to on_temp_change.

Handlers fire on ualib’s internal delivery thread. They MUST be non-blocking and MUST NOT throw. Unsubscribe() from inside a handler is safe; Connect / Disconnect / Configure is not.

Connection-state events

SetConnectionStateHandler registers a callback that fires on every channel/session transition (Disconnected → Connecting → Connected, Connected → Faulted on link drop, etc.). The handler MUST be installed before Connect() — otherwise the initial transitions are missed.

The returned RegistrationToken is RAII: when it leaves scope (or Reset() is called), the handler is detached and no more events fire.

eso::uatools::Client client;
auto token = client.SetConnectionStateHandler(
    [](const fnd::ConnectionStateChange& evt) {
        // evt.previous, evt.current, evt.reason, evt.message
    });
client.Configure(opts);
client.Connect();   // fires Disconnected -> Connecting -> Connected

Cancellation (cooperative)

ualib honours RequestOptions::cancellation cooperatively:

  • A pre-flight check at the top of each op: if the token is already set, the call returns Status::Cancelled before touching the SDK.

  • A per-item check inside batch loops (Read, Write, Browse): the partial result up to that point is returned, with the remaining items marked Status::Cancelled.

In-flight cancellation of an individual SDK call is not possible — open62541’s sync API has no abort path. The contract calls this cooperative; it lets the caller cleanly abort a long-running batch or BFS traversal while it’s between items.

fnd::CancellationSource src;
fnd::ReadRequest req;
req.options.cancellation = src.Token();
req.items = ... /* many */;

std::thread aborter([&] {
    std::this_thread::sleep_for(std::chrono::seconds(2));
    src.Cancel();
});
auto res = client.Read(req);
aborter.join();
// res.status will be Cancelled if the batch was interrupted

Logging

ualib uses ifw::fnd’s Logger abstraction. InstallLogger() is required once at process startup; otherwise the first FND* call throws. The FNDTRACE / FNDDEBUG / FNDINFO / FNDWARNING / FNDERROR / FNDTHROW macros include a cheap early-out filter, so disabled-level calls cost a single integer compare.

ualib emits:

  • FNDTRACE() at every public Client method entry. Useful for step-tracing.

  • FNDDEBUG at lifecycle transitions and per-item operations.

  • FNDINFO on Connected / Disconnected.

  • FNDWARNING when a public op is rejected (NotConnected guards).

  • FNDERROR on hard failures (alloc, SDK refusal).

open62541’s internal logger is wired through a separate filter, tunable via ConnectionOptions::properties.Set("sdk_log_level", "off"|"error"|...). The default is off (silent). UaClient’s -l/--log-level flag sets both the ifw-fnd and the SDK threshold together.

See the “Logging — ELT / VLTSW / standalone” section of the README for how to plug a CII or VLT-CCS logger in.