Packages

OPC UA in pure Elixir: client, server, events and alarms, security policies, and PubSub over UDP, for talking to PLCs, SCADA and HMIs without C in the BEAM.

Current section

Files

Jump to
yaopcua README.md
Raw

README.md

# 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](https://github.com/open62541/open62541)
does, written from the OPC UA specification (free to read at
[reference.opcfoundation.org](https://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](#whats-missing).
## Client
```elixir
{: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:
```elixir
{: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.Variant`s 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
```elixir
{: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:
```elixir
: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](#whats-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` |
```elixir
# 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](#whats-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.
```elixir
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](#whats-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:
```elixir
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](https://github.com/OPCFoundation/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.ExtensionObject`s; 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/1` on 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/4` sends plain integers as Int32 and floats as Double; other types
need an `OPCUA.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_publish` and 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](https://github.com/FreeOpcUa/opcua-asyncio),
a Python OPC UA stack written independently of this one, in two ways:
* `test/vectors/asyncua.txt` holds about 1,200 structures encoded by asyncua.
yaopcua must decode each one and encode it back to the same bytes. These run
with every `mix test`; `test/vectors/generate_asyncua.py` regenerates 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 asyncua
ASYNCUA_PYTHON=~/.venvs/asyncua/bin/python mix test
```
Python only ever runs in tests, never inside yaopcua.
`test/opcua/fuzz_test.exs` fuzzes with
[StreamData](https://github.com/whatyouhide/stream_data), 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](https://github.com/whatyouhide/stream_data): 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](#whats-missing).
## License
Apache License 2.0; see [LICENSE](https://github.com/tallakt/yaopcua/blob/main/LICENSE). The OPC Foundation's definition
files in `schema/` keep their own license, the OPC Foundation MIT License
1.00; see [NOTICE](https://github.com/tallakt/yaopcua/blob/main/NOTICE).