yamodbus

CI Hex.pm Documentation License

Implemented by AI under the supervision of Tallak Tveide.

Yet another Modbus: an independent Modbus stack in pure Elixir, for talking to PLCs, drives, meters and gateways.

It does what the Modbus Organization's specifications define, and nothing on top: the application protocol, Modbus over TCP, Modbus/TCP Security (TLS), and RTU and ASCII on serial lines. It was written from those specifications, which are free to download at modbus.org, not from another stack's code. Registers are 16-bit words and coils are booleans, as on the wire: floats, 32-bit numbers, word order and the "40001" numbering are the application's business.

Status: clients and servers on TCP, TLS, RTU and ASCII, with every public function code of the spec; tested against pymodbus both ways on every transport, and against libmodbus both ways on TCP and RTU. What's left is under What's missing.

Client

{:ok, client} = Modbus.Client.start_link(tcp: "10.0.0.20")
{:ok, [1500, 7]} = Modbus.Client.read_holding_registers(client, 1, 100, 2)
:ok = Modbus.Client.write_single_coil(client, 1, 5, true)
{:ok, %{0 => "Acme", 1 => "PD-100", 2 => "2.11"}} = Modbus.Client.read_device_identification(client, 1)
{:error, {:exception, :illegal_data_address}} = Modbus.Client.read_coils(client, 1, 9999, 1)
{:error, :timeout} = Modbus.Client.read_coils(client, 9, 0, 1)

The second argument is the unit id. Modbus.Client.request/4 sends any request as a tuple, and send_request/4 sends one without waiting, the result coming as a message:

{:ok, words} = Modbus.Client.request(client, 1, {:read_input_registers, 0, 10}, timeout: 500)
ref = Modbus.Client.send_request(client, 1, {:read_holding_registers, 0, 125})
receive do
{Modbus.Client, ^ref, {:ok, words}} -> words
end
Request Function
{:read_coils, address, count}, {:read_discrete_inputs, address, count} 1, 2
{:read_holding_registers, address, count}, {:read_input_registers, address, count} 3, 4
{:write_single_coil, address, boolean}, {:write_single_register, address, word} 5, 6
{:write_multiple_coils, address, [boolean]}, {:write_multiple_registers, address, [word]} 15, 16
{:mask_write_register, address, and_mask, or_mask} 22
{:read_write_multiple_registers, read, count, write, [word]} 23
{:read_fifo_queue, address} 24
{:read_file_record, [{file, record, count}]}, {:write_file_record, [{file, record, [word]}]} 20, 21
{:read_device_identification, category, object_id} 43/14
:read_exception_status, {:diagnostics, sub_function, [word]}, :get_comm_event_counter, :get_comm_event_log, :report_server_id 7, 8, 11, 12, 17
{:encapsulated_interface_transport, mei_type, binary} 43
{:custom, function_code, binary} user-defined, 65 to 72 and 100 to 110

The results, and the errors, are listed in the docs of Modbus. A request that doesn't fit the protocol, such as reading 200 registers at once, raises in the caller and is never sent.

Many requests at once

Over TCP, up to max_pending: requests (4 by default) are on the way at once on one connection, each under a transaction id of its own, so a slow device or gateway doesn't hold up the others' answers. More wait their turn in the client. On a serial line there's one request at a time, as the spec has it.

Connections

start_link/1 returns at once and connects in the background; a request made while it's connecting waits for it. When the connection drops, the client reconnects by itself, after 100 ms at first and up to 5 seconds between tries. Meanwhile requests fail at once with {:error, :closed}, and status/1 says why. Two requests in a row that time out with nothing at all heard from the device since the first was sent mean the connection is dead, as one a rebooted device or a broken cable left half open, and the client reconnects; a device that's only slow keeps its connection.

The client never logs, and never exits the caller: every request gets a result within its timeout.

Server

{:ok, memory} = Modbus.Memory.start_link(holding_registers: 1000)
{:ok, server} = Modbus.Server.start_link(port: 502, handler: {Modbus.Memory, memory})
Modbus.Memory.put(memory, :holding_register, 0, [215, 1013])

A handler is a function of the unit and the request, or a module with the Modbus.Server behaviour, that gives the result a client would get:

Modbus.Server.start_link(port: 502, handler: fn
_unit, {:read_input_registers, 0, 2} -> {:ok, [Sensor.temperature(), Sensor.pressure()]}
_unit, _request -> {:error, {:exception, :illegal_function}}
end)

Modbus.Memory keeps the four tables of the data model and files of records, and answers the functions that read and write them, with the exceptions the spec gives for addresses that aren't there.

Since a handler gives what a client gets, a gateway is a few lines; a client's timeout or lost connection becomes the gateway exceptions:

{:ok, line} = Modbus.Client.start_link(rtu: "/dev/ttyUSB0", speed: 9600)
Modbus.Server.start_link(port: 502, handler: fn unit, request -> Modbus.Client.request(line, unit, request) end)

With identification:, a map of object ids to strings, the server answers Read Device Identification itself, by category and over as many responses as the objects take.

Serial lines

{:ok, client} = Modbus.Client.start_link(rtu: "/dev/ttyUSB0", speed: 19200, parity: :even)
{:ok, server} = Modbus.Server.start_link(ascii: "/dev/ttyS1", units: [3], handler: handler)

RTU and ASCII go through circuits_uart, an optional dependency that an application using them adds:

{:circuits_uart, "~> 1.5"}

Its C code runs as a port, an OS process of its own, so a crash there closes the port without taking down the BEAM; the client or server opens it again.

Neither the client nor the server times the silence between RTU frames, which the BEAM's timers and the buffering of USB serial adapters can't do to a fraction of a millisecond. A frame ends where its function code and byte counts say, and the CRC confirms it; on a busy line, bytes that start no frame are passed over until one checks out. Silence ends only frames whose length nothing tells, such as user-defined functions.

Unit 0 broadcasts a write to every device, and a client waits out the turnaround delay rather than an answer. A server answers its units:, acts on broadcasts, and keeps the diagnostic counters and event log of the serial line spec, answering Diagnostics (8), Get Comm Event Counter (11) and Get Comm Event Log (12) itself, listen only mode included.

Some RS-485 adapters echo what they send. For a write, the echo is the same bytes as the device's answer, and would pass for it though the device were dead: echo: true, on a client or a server, passes over it.

Security

Modbus/TCP Security is Modbus TCP inside TLS 1.2 or later, on port 802, with a certificate on both sides. A client's certificate may carry a role, which the server passes to authorize: with each request; one it refuses gets an illegal function exception, as the spec has it.

Modbus.Server.start_link(handler: handler,
ssl: [certfile: "server.pem", keyfile: "server.key", cacertfile: "plant-ca.pem"],
authorize: fn
"Operator", _unit, _request -> true
_role, _unit, request -> elem(request, 0) in [:read_holding_registers, :read_input_registers]
end)
Modbus.Client.start_link(tls: "10.0.0.5",
ssl: [certfile: "client.pem", keyfile: "client.key", cacertfile: "plant-ca.pem"])
Client Server
TLS 1.2 or 1.3, never older; no SHA-1 cipher suites the same
Certificates checks the server's against cacerts/cacertfile; sends its own refuses a client without a certificate it trusts
Roles carried in its certificate (OID 1.3.6.1.4.1.50316.802.1) given to authorize:, nil if there's none

The other ssl: options pass through to OTP's :ssl.

Hostile clients and networks

A server on a plant network should expect anything on its port:

Attack What stops it
Many connections, or stale ones a client left behind 16 at most; when one more comes, the one longest without a request is closed, as the TCP guide recommends, among those of the address that holds the most, so a host that floods the port pushes out only its own
Hosts that have no business on the port allow: [{{10, 0, 0, 0}, 8}] lets in only the addresses listed, as the TCP guide suggests
A flood of connections reset as soon as they're made each is passed over at once, without a pause for the next client to wait out
A connection that trickles bytes, or says nothing closed after idle: (a minute) without a whole request; a TLS handshake has 10 seconds
Malformed frames a header that isn't Modbus's closes the connection; a request that breaks its function's rules gets :illegal_data_value, and the connection goes on
A handler that crashes, hangs, or answers wrong :server_device_failure for that request alone, a hanging one after handler_timeout: (10 seconds); a gateway's lost connection or timeout on the far side are the gateway exceptions
Requests sent faster than they're answered each connection reads its next request only when it has answered the last, so TCP holds the rest back

On a serial line, anyone on the bus can do anything; still, a server won't take an ASCII delimiter that can come inside a frame, which would leave it unable to read one again, and authorize: decides on the diagnostics it answers itself as on everything else.

A client can meet a hostile server too. It takes only answers that fit their request, in unit id, function code, count and the echo of a write, and drops answers to requests it no longer waits for, so one can't be taken for another's. A device that doesn't copy the unit id back, as the spec says it must, needs check_unit: false; its answers are then matched by transaction id alone. Only whole frames count as hearing from a device, so one that trickles bytes doesn't keep a dead connection open.

What's missing

Tests

The examples in the application protocol spec are tests, one for each function. The interop tests run the client against a pymodbus server, and pymodbus's client against the server, over TCP, TLS, RTU and ASCII, in a separate process from test/support/. They need a Python with pymodbus, and are skipped without one:

python3 -m venv ~/.venvs/pymodbus && ~/.venvs/pymodbus/bin/pip install pymodbus pyserial
PYMODBUS_PYTHON=~/.venvs/pymodbus/bin/python mix test

The libmodbus tests do the same against a peer in test/support/libmodbus_peer.c, built when the tests start, over TCP and RTU. They run where pkg-config finds libmodbus (brew install libmodbus, or apt install libmodbus-dev), as separate processes: C only ever runs in tests, never inside yamodbus.

The serial line tests join two pseudo-terminals with socat, and are skipped where it isn't installed.

test/modbus/property_test.exs fuzzes with StreamData: every request and result must encode and decode back to itself, random bytes go into every decoder and framing, frames split anywhere must come back whole, and a running server and client get random frames and answers. Nothing may crash. Each property runs a hundred or more cases with mix test; for more:

FUZZ_RUNS=10000 mix test test/modbus/property_test.exs
FUZZ_SECONDS=600 mix test test/modbus/property_test.exs

CI also runs Credo and Dialyzer, and on the newest Elixir mix test --cover fails below 82% of the code covered (about 88% is).

License

Copyright 2026 Tallak Tveide. Licensed under the Apache License, Version 2.0; see LICENSE and NOTICE.