yaopcua
Implemented by AI under the supervision of Tallak Tveide.
Yet another OPC UA: an independent OPC UA stack in pure Elixir, for talking to PLCs, SCADA systems and HMIs without running C inside the BEAM.
It implements a subset of what open62541 does, written from the OPC UA specification (free to read at reference.opcfoundation.org) and the OPC Foundation's machine-readable definitions, not from another stack's code. It speaks the binary protocol over TCP only; the XML and JSON encodings are out.
Status: a client and a server that read, write, browse, call methods, subscribe to value changes and events, and handle alarms, over signed and encrypted channels; and PubSub over UDP for PLC to PLC. What's left is under What's missing.
Client
{:ok, client} = OPCUA.Client.start_link(url: "opc.tcp://10.0.0.5:4840")
{:ok, 1500} = OPCUA.Client.read(client, "ns=2;s=Pump1.Speed")
{:ok, [1.0, 2.0, 3.0]} = OPCUA.Client.read(client, "ns=2;s=Tank.Setpoints")
:ok = OPCUA.Client.write(client, "ns=2;s=Pump1.Speed", 1600)
{:ok, refs} = OPCUA.Client.browse(client, "ns=2;s=Plant")
{:ok, [42]} = OPCUA.Client.call(client, "ns=2;s=Plant", "ns=2;s=Multiply", [6, 7])
{:error, :bad_node_id_unknown} = OPCUA.Client.read(client, "ns=2;s=Nope")
Subscriptions send messages to the subscriber, the caller by default:
{:ok, sub} = OPCUA.Client.subscribe(client, ["ns=2;s=Pump1.Speed", "ns=2;s=Tank.Level"], interval: 100)
# first the current values, then each change
receive do
{OPCUA.Client, ^sub, {:value, "ns=2;s=Pump1.Speed", %OPCUA.DataValue{value: %{value: speed}}}} -> speed
end
{:ok, alarms} = OPCUA.Client.subscribe_events(client, fields: ["Message", "Severity", "ActiveState/Id"])
receive do
{OPCUA.Client, ^alarms, {:event, %{"Message" => message, "Severity" => severity}}} -> {message, severity}
end
| Function | Does |
|---|---|
read/3 |
reads one attribute of one node, the value by default, as a plain Elixir value |
read_many/3 |
reads several nodes, with status and timestamps (OPCUA.DataValue) |
write/3, write_many/2 |
writes plain values in the node's own type (the client reads the node once to learn it), or OPCUA.Variants as given |
browse/3 |
lists a node's references, following continuation points |
call/4 |
calls a method and returns its outputs |
subscribe/3 |
sends the subscriber each change of some values, optionally with a deadband |
subscribe_events/2 |
sends the subscriber the events a node reports, with the fields asked for, optionally only of one type |
acknowledge/4, refresh/2 |
acknowledges an alarm; asks for the standing alarms again (ConditionRefresh) |
unsubscribe/2 |
ends a subscription; it also ends when the subscriber exits |
request/3 |
sends any service request from OPCUA.Types |
endpoints/2 |
asks a server which endpoints and logins it offers, without a session |
Log in with user: {"operator", "secret"} or user: {:certificate, der, key};
anonymous is the default.
The client keeps the session alive and renews the secure channel before it
expires. When the connection drops it stops with {:shutdown, reason}, so run
it under a supervisor to reconnect.
Server
{:ok, server} = OPCUA.Server.start_link(port: 4840)
2 = OPCUA.Server.namespace(server, "urn:plant")
:ok = OPCUA.Server.add_object(server, "ns=2;s=Pump1", "Pump1")
:ok = OPCUA.Server.add_variable(server, "ns=2;s=Pump1.Speed", "Speed",
parent: "ns=2;s=Pump1", type: :int16, value: 1500, writable: true)
:ok = OPCUA.Server.add_variable(server, "ns=2;s=Pump1.Temp", "Temp",
parent: "ns=2;s=Pump1", type: :double, read: fn -> read_sensor() end)
:ok = OPCUA.Server.add_method(server, "ns=2;s=Pump1.Start", "Start",
parent: "ns=2;s=Pump1", inputs: [{"speed", :int16}], outputs: [],
call: fn [speed] -> start_pump(speed) && {:ok, []} end)
# from the application, whenever the value changes
:ok = OPCUA.Server.set(server, "ns=2;s=Pump1.Speed", 1510)
| Function | Does |
|---|---|
namespace/2 |
the index of a namespace URI, adding it if new |
add_folder/4, add_object/4 |
adds a folder or an object, under the Objects folder by default |
add_variable/4 |
adds a variable with a stored value, or one read from a function each time; :write sees and may refuse each client write |
add_method/4 |
adds a method; the function gets the inputs as plain values |
set/3, get/2 |
sets and gets a value from the application; a value that doesn't fit raises |
event/2 |
sends an event to the clients that subscribe to events |
add_condition/4, condition/3 |
adds an alarm on a node, and changes it: active, acknowledged, enabled, severity, message |
space/1 |
the address space itself, for set/3 without going through the server process |
The server starts with all 5,500 standard nodes of namespace 0, and answers GetEndpoints, FindServers, sessions (anonymous and username logins), Read, Write, Browse and BrowseNext, TranslateBrowsePathsToNodeIds, Call, RegisterNodes, and the subscription services: value changes with deadbands and queues, keep-alives, lifetimes and Republish. Each client connection runs in its own process.
Alarms
An alarm is a condition (Part 9) on the node it's about. Clients see it through events, and acknowledge it through the server, which asks the application first:
:ok = OPCUA.Server.add_condition(server, "ns=2;s=Pump1.Overload", "Overload",
source: "ns=2;s=Pump1", severity: 700, message: "Pump 1 overload",
acknowledge: fn comment -> MyPlant.acknowledge(:pump_overload, comment) end)
:ok = OPCUA.Server.condition(server, "ns=2;s=Pump1.Overload", active: true)
| The application | What clients get |
|---|---|
condition(..., active: true) |
an event with ActiveState true, AckedState false, Retain true |
| a client calls Acknowledge | the :acknowledge function, then an event with AckedState true and the comment |
condition(..., active: false) |
an event with ActiveState false, and Retain false once acknowledged |
| a client calls ConditionRefresh | the retained alarms again, between RefreshStart and RefreshEnd events |
Event filters take select clauses by browse path (including ConditionId) and where clauses with OfType, And, Or, Not, the comparisons, Between, InList and IsNull. Enable, Disable and AddComment work too; Confirm and shelving don't.
What the server doesn't do yet is listed under What's missing.
Security
Both the client and the server sign, or sign and encrypt, their channels with
the current security policies, using only OTP's :crypto and :public_key:
| Policy | Name here |
|---|---|
| Basic256Sha256 | :basic256sha256 |
| Aes128_Sha256_RsaOaep | :aes128_sha256_rsa_oaep |
| Aes256_Sha256_RsaPss | :aes256_sha256_rsa_pss |
# once, and keep the files: peers trust a certificate, not a name
{cert, key} = OPCUA.Certificate.self_signed("urn:plant:server", hostnames: ["plc1.local"])
OPCUA.Certificate.write("server.der", cert)
OPCUA.Certificate.write_key("server.pem", key)
{:ok, server} = OPCUA.Server.start_link(
security: [:basic256sha256, :aes256_sha256_rsa_pss],
certificate: cert, private_key: key,
trust: [OPCUA.Certificate.read("scada.der")],
users: %{"operator" => "secret"})
{:ok, client} = OPCUA.Client.start_link(
url: "opc.tcp://plc1.local:4840",
security: {:aes256_sha256_rsa_pss, :sign_and_encrypt},
trust: [OPCUA.Certificate.read("server.der")],
certificate: scada_cert, private_key: scada_key,
user: {"operator", "secret"})
| Client | Server | |
|---|---|---|
| Channels | the policy and mode asked for; the server's certificate must be in :trust |
an endpoint per policy and mode in :security; the client's certificate must be in :trust |
| Sessions | checks the server's signature, signs its own | checks the client's signature and application URI, signs its own |
| Passwords | encrypted for the server whenever it asks, even over None | decrypted; over None it asks for its strongest policy |
| User certificates | signs with the user's key | accepts those in :user_certificates |
A secure client or server won't start without being told what to trust
(:trust, a list of certificates or :any). See
What's missing for the policies and certificate handling
that aren't there.
PubSub
For controller-to-controller data: a publisher sends datasets over UDP every interval, to a multicast group or one host, and any number of subscribers pick out the ones they want. There are no sessions, and nothing to answer.
fields = [{"Speed", :int16}, {"Level", :double}, {"Running", :boolean}]
# on one PLC
{:ok, _} = OPCUA.PubSub.Publisher.start_link(
url: "opc.udp://239.0.0.1:4840", publisher_id: 42, interval: 50,
writers: [[id: 1, fields: fields, read: fn -> Pump.values() end]])
# on another
{:ok, _} = OPCUA.PubSub.Subscriber.start_link(
url: "opc.udp://239.0.0.1:4840",
readers: [[publisher_id: 42, writer_id: 1, fields: fields, timeout: 200]])
# which then gets, every 50 ms
{OPCUA.PubSub, {42, 1}, {:data, %{"Speed" => 1500, "Level" => 2.5, "Running" => true}}}
# and if the publisher goes quiet for 200 ms
{OPCUA.PubSub, {42, 1}, :timeout}
| Option | |
|---|---|
encoding: |
:variant (typed, the default), :raw (smallest; both ends must agree on the types) or :data_value (with status and timestamps) |
key_frames: |
every value each N-th cycle, and only the changed ones in between |
read: or Publisher.set/3 |
where the publisher's values come from |
Messages are UADP (Part 14), checked against asyncua in both directions. See What's missing for what PubSub lacks.
Encoding
Every structure and enumeration of the spec is a module under OPCUA.Types,
with the spec's field names in snake case:
alias OPCUA.Types.{ReadRequest, ReadValueId, RequestHeader}
request = %ReadRequest{
request_header: %RequestHeader{request_handle: 1, timeout_hint: 5000},
timestamps_to_return: :both,
nodes_to_read: [
%ReadValueId{node_id: OPCUA.NodeId.parse!("ns=3;s=Pump1.Speed"), attribute_id: OPCUA.AttributeId.id(:value)}
]
}
bytes = request |> ReadRequest.encode() |> IO.iodata_to_binary()
{:ok, ^request, ""} = OPCUA.Binary.decode(bytes, ReadRequest)
A structure field left nil, like a missing request_header, is sent as that
structure's defaults, and comes back as the default struct.
The built-in types have their own structs and conventions:
| OPC UA | Elixir |
|---|---|
| Int32, Double, ... | numbers; NaN and infinities are :nan, :infinity, :neg_infinity |
| String, ByteString | binaries, nil when null |
| DateTime | UTC DateTime with microseconds, nil for 0 |
| Guid | "72962B91-FA75-4AE6-8D28-B404DC7DAF63" |
| NodeId | %OPCUA.NodeId{ns: 3, id: "Pump1.Speed"}, or nil for i=0 |
| StatusCode | an integer; OPCUA.StatusCode.name(0x80340000) is :bad_node_id_unknown |
| Variant | %OPCUA.Variant{type: :int16, value: 42}, a list for arrays |
| DataValue | %OPCUA.DataValue{value: variant, status: 0, source_timestamp: ...} |
| ExtensionObject | the OPCUA.Types struct inside it, or %OPCUA.ExtensionObject{} for a type yaopcua doesn't know |
| Enumeration | an atom, such as :both |
OPCUA.Binary has the details, and OPCUA.NodeIds looks up the standard nodes
by name: OPCUA.NodeIds.id("Server_ServerStatus") is 2256.
The spec version
The types are generated at compile time from the OPC Foundation's own files,
copied unmodified into schema/ from one release of their
UA-Nodeset repository:
| File | Gives |
|---|---|
Opc.Ua.Types.bsd |
the layout of every structure and enumeration |
Opc.Ua.NodeSet2.xml |
the standard nodes the server starts with |
NodeIds.csv |
the ids of the standard nodes, including each structure's wire id |
StatusCode.csv |
status code names and descriptions |
AttributeIds.csv |
attribute ids |
VERSION |
the release tag, e.g. UA-1.05.07-2026-07-30 |
So each yaopcua version builds against exactly one OPC UA release, and
OPCUA.Schema.version/0 says which. To move to another release, give its tag:
mix opcua.schema UA-1.05.07-2026-07-30
mix test
The diff then shows the Foundation's own changes. The .bsd and the NodeSet
are XML, read with OTP's xmerl while compiling; nothing reads XML at runtime.
What's missing
yaopcua is a subset of OPC UA. Beyond the XML and JSON encodings, which are out on purpose, this is what it doesn't do, by area.
Encoding
- Custom structures from a server's own namespaces stay undecoded
OPCUA.ExtensionObjects; decoding them from their DataTypeDefinition is on the roadmap. Structures with optional fields or unions (spec 1.04+) aren't supported either. - DateTime keeps microseconds, dropping the last 100 ns digit.
- A Variant with a built-in type id above 25 is a decoding error.
- XML bodies of ExtensionObjects are kept as text, never parsed.
- Option sets are integers;
flags/1on their module names the bits.
Connections
- UA-TCP over IPv4 only: no IPv6, HTTPS, WebSockets or reverse connect.
- Messages are capped at 16 MB and 4096 chunks.
Security
- The ECC policies, and the deprecated Basic128Rsa15 and Basic256.
- Certificate chains in the handshake: each side sends and expects a single certificate.
- Revocation lists, a rejected-certificate folder, and certificate management from a Global Discovery Server.
- The client trusts the server's certificate by the trust list alone; it doesn't compare the certificate's host names or URI with the endpoint.
- RSA-OAEP goes through functions OTP 27 deprecates (see
OPCUA.SecurityPolicy).
Client
- No reconnecting: when the connection drops the client stops, and a supervisor must start a new one, with new sessions and subscriptions.
- Notifications lost on the way aren't asked for again (Republish), and subscriptions can't move to another session (TransferSubscriptions).
- History, Query, node management (AddNodes and so on), SetTriggering and
discovery beyond GetEndpoints; the other services are there through
request/3, without helpers. call/4sends plain integers as Int32 and floats as Double; other types need anOPCUA.Variant.
Server
- Sessions end with their connection, so a client can't reactivate one, or its subscriptions, on a new connection.
- No history, Query, node management from clients, SetTriggering or TransferSubscriptions: they're answered with BadServiceUnsupported.
- No views, no DataTypeDefinition or RolePermissions attributes, and only the Value attribute can be written, without index ranges.
- Variables are of the 25 built-in types, scalar or one-dimensional arrays; no custom data types or enumerations.
- Most of namespace 0 has no values: ServerCapabilities, diagnostics and the like read as empty. Its methods, such as GetMonitoredItems, answer BadNotImplemented.
- No limits on connections, sessions, subscriptions or monitored items per client, and fixed ones elsewhere (10,000 operations per request, 1,000 references per browse, 16 continuation points per session).
- Subscriptions ignore
max_notifications_per_publishand priority, and don't set the queue overflow bit. Items are sampled on a timer that ticks at the subscription's fastest sampling interval, so a slower item may be up to one tick late. Percent deadbands need an EURange, which isn't there. - A renewed secure channel token is used for sending at once, rather than after the client first uses it.
- Events: the where-clause operators Like, Cast, InView, RelatedTo and the bitwise ones; AttributeOperand; index ranges in select clauses; overflow events for full queues. Events reach the Server object, their source and what's above the source by HasEventSource and HasNotifier.
- Alarms: no Confirm, shelving, suppression, silencing, latching or branches; no fields of specific alarm types (such as limits); and no Acknowledge method node on each condition, so clients call the one on the type.
- The address space isn't saved; the application builds it on each start.
- The server's certificate, if not given, is made anew on each start.
- No registration with discovery servers, and no mDNS.
PubSub
- Message security and the Security Key Service.
- Network messages chunked over several datagrams, discovery messages, and dataset metadata; promoted fields are skipped.
- Transports other than UDP: Ethernet (TSN), MQTT, AMQP; and the JSON message mapping.
- Configuration as nodes in the server, or from a configuration file.
- Event datasets: decoded, but not published or passed on.
Quality
- Tested against asyncua only, not against open62541, the OPC Foundation's .NET stack, real PLCs, or the Compliance Test Tool.
- Fuzzed in runs of hours, not continuously the way OSS-Fuzz fuzzes open62541, and without coverage guidance.
- Performance hasn't been measured.
Tests
yaopcua is tested against asyncua, a Python OPC UA stack written independently of this one, in two ways:
-
test/vectors/asyncua.txtholds about 1,200 structures encoded by asyncua. yaopcua must decode each one and encode it back to the same bytes. These run with everymix test;test/vectors/generate_asyncua.pyregenerates them. -
The interop tests run the client against an asyncua server, and asyncua's client against the server, as separate processes from
test/support/, with and without every security policy and mode. They need a Python with asyncua, and are skipped without one:python3 -m venv ~/.venvs/asyncua && ~/.venvs/asyncua/bin/pip install asyncuaASYNCUA_PYTHON=~/.venvs/asyncua/bin/python mix test
Python only ever runs in tests, never inside yaopcua.
test/opcua/fuzz_test.exs fuzzes with
StreamData, in the spirit of
open62541's fuzzers. Random and mutated bytes go into:
- every decoder;
- the transport framing;
- the secure channel, with each policy and mode;
- UADP, and a running subscriber;
- a running server, on new connections and on open sessions.
Random requests go to the server too, for every service except Publish and
those that manage the channel and session, and random arguments to its
methods and alarm methods. Nothing may crash. The server must
answer or refuse, and whatever decodes must encode and decode back to itself. Each property runs 100 cases with mix test. For more:
FUZZ_RUNS=10000 mix test test/opcua/fuzz_test.exs # cases per property
FUZZ_SECONDS=600 mix test test/opcua/fuzz_test.exs # or seconds per property
StreamData generates values from the types, where libFuzzer follows code coverage. It knows every structure's shape, but it won't work its way into a rare branch.
Roadmap
| Feature | Spec | |
|---|---|---|
| ✅ | Binary encoding of the built-in types and all generated structures | Part 6 |
| ✅ | UA-TCP transport, secure channel (policy None), sessions | Parts 4, 6 |
| ✅ | Client: read, write, browse, method calls, anonymous and username login | Part 4 |
| ✅ | Client: subscriptions to value changes and events | Part 4 |
| Client: custom structures, reconnecting | Part 4 | |
| ✅ | Server: address space with namespace 0, callback variables, methods, subscriptions | Parts 3, 4, 5 |
| ✅ | Events and alarms in the server: event filters, conditions, acknowledge, ConditionRefresh | Parts 4, 9 |
| ✅ | Security: Basic256Sha256, Aes128_Sha256_RsaOaep and Aes256_Sha256_RsaPss, encrypted passwords and certificate logins, with OTP's :crypto and :public_key only |
Parts 2, 4, 6, 7 |
| ✅ | PubSub: UADP over UDP, unicast and multicast, key and delta frames | Part 14 |
| ✅ | Fuzzing with StreamData: random and mutated bytes into the decoder, the secure channel and the server |
Out of scope on purpose: the XML and JSON encodings. Everything else not done is under What's missing.
License
Apache License 2.0; see LICENSE. The OPC Foundation's definition
files in schema/ keep their own license, the OPC Foundation MIT License
1.00; see NOTICE.