modbuv
modbuv is a high-performance, robust Elixir/OTP Modbus library driven by numeric function codes (1..127). It provides complete support for Modbus TCP (Client & Server) and Modbus RTU (Master & Slave).
Unlike legacy libraries that bind commands to fixed atomic symbols (e.g., :rhr, :rc), modbuv handles any standard or vendor-specific function code without modifying the library or configuring translations.
Features
- Numeric Function-Code Driven: Function codes (
1..127) are passed directly into the API without hardcoded symbol tables. - Vendor-Specific Function Codes: Seamlessly supports proprietary function codes through configurable
FcMapregistries. - Robust Framing & Deframing:
- TCP: Full MBAP header stream framing, handling split packets and coalesced messages.
- RTU: Sliding-window byte-by-byte resynchronization and dynamic frame length deduction via
Circuits.UART.Framing.
- Concurrency & Serialization: RTU Master request serialization with
:busyprotection. - Dual Operating Modes: Both
active(asynchronous event streaming) andpassive(synchronous call/confirmation) modes for all roles. - Atomic Multi-Writes: All-or-nothing in-memory register/coil transactions.
- Pure Function Decoupling: Pure PDU encoding/decoding and DB state transitions separate from OTP transport processes.
- Standard Helpers: IEEE754 32-bit float conversions (big-endian and little-endian word orders) and fast table-driven CRC16.
- Telemetry: Start/stop events with RTT for TCP Client and RTU Master requests.
Installation
Add modbuv to your list of dependencies in mix.exs:
def deps do
[
{:modbuv, "~> 0.1.0"}
]
end
Core Frame Model
Every request frame is represented as a 4-element tuple:
{slave, {fc, direction, width}, address, argument}
| Field | Type | Description |
|---|---|---|
slave |
0..255 |
RTU slave address or TCP Unit ID |
fc |
1..127 |
Numeric Modbus function code |
direction |
:r | :w |
:r for read, :w for write |
width |
:bit | :reg |
:bit for coils/discrete inputs, :reg for 16-bit registers |
address |
0..65535 |
0-based starting address |
argument |
integer / list | Read: count (positive integer). Single-write: value (integer). Multi-write: values (non-empty list). |
Examples
# Standard reads
{1, {1, :r, :bit}, 0, 10} # Read 10 coils starting from address 0
{1, {3, :r, :reg}, 100, 2} # Read 2 holding registers starting from address 100
# Standard writes
{1, {5, :w, :bit}, 0, 1} # Write single coil 0 to ON (1)
{1, {6, :w, :reg}, 100, 1234} # Write single holding register 100
{1, {15, :w, :bit}, 0, [1, 0, 1]} # Write multiple coils
{1, {16, :w, :reg}, 100, [10, 20]} # Write multiple holding registers
# Vendor-specific function codes
{1, {0x64, :r, :reg}, 0, 4} # Vendor function code 0x64 (read 4 registers)
{1, {0x6E, :w, :reg}, 0, [5, 6]} # Vendor function code 0x6E (multi-register write)
Usage Guide
1. Modbus TCP Client
Passive Mode (Synchronous)
alias Modbuv.Tcp.Client
# Start and connect
{:ok, c} = Client.start_link(ip: {192, 168, 1, 10}, tcp_port: 502, timeout: 2_000)
:ok = Client.connect(c)
# Read holding registers
:ok = Client.request(c, {1, {3, :r, :reg}, 0, 2})
{:ok, [10, 20]} = Client.confirmation(c)
# Write holding registers
:ok = Client.request(c, {1, {16, :w, :reg}, 0, [30, 40]})
:ok = Client.confirmation(c)
# Clean up
Client.close(c)
Client.stop(c)
Active Mode (Asynchronous Notifications)
# Responses are delivered directly to d_pid (defaults to calling process)
{:ok, c} = Client.start_link(ip: {127, 0, 0, 1}, tcp_port: 502, active: true, d_pid: self())
:ok = Client.connect(c)
:ok = Client.request(c, {1, {3, :r, :reg}, 0, 2})
receive do
{:modbuv_tcp, {1, {3, :r, :reg}, 0, 2}, [10, 20]} ->
IO.puts("Received registers asynchronously!")
{:modbuv_tcp, frame, {:error, reason}} ->
IO.puts("Request failed: #{inspect(reason)}")
end
2. Modbus TCP Server
alias Modbuv.Tcp.Server
# Define initial data model
# Format: %{slave_id => %{{region, address} => value}}
# Regions: :c (coil), :i (discrete input), :hr (holding register), :ir (input register)
model = %{
1 => %{
{:c, 0} => 1,
{:hr, 0} => 10,
{:hr, 1} => 20
}
}
# Start server listening on port 502 (or 0 for ephemeral port)
{:ok, server} = Server.start_link(port: 502, model: model)
# Directly update or query server DB from Elixir
Server.update(server, {1, {6, :w, :reg}, 0, 99})
[99] = Server.update(server, {1, {3, :r, :reg}, 0, 1})
db = Server.get_db(server)
# Stop server cleanly
Server.stop(server)
3. Modbus RTU Master
alias Modbuv.Rtu.Master
# Start master on serial port
{:ok, master} =
Master.start_link(
tty: "/dev/ttyUSB0",
timeout: 1_000,
uart_opts: [speed: 9600, data_bits: 8, stop_bits: 1, parity: :none]
)
# Send request (Master automatically derives response length and verifies CRC)
{:ok, [10, 20]} = Master.request(master, {1, {3, :r, :reg}, 0, 2})
:ok = Master.request(master, {1, {6, :w, :reg}, 0, 99})
# Stop master
Master.stop(master)
4. Modbus RTU Slave
alias Modbuv.Rtu.Slave
model = %{
1 => %{
{:hr, 0} => 10,
{:hr, 1} => 20
}
}
{:ok, slave} =
Slave.start_link(
tty: "/dev/ttyUSB1",
model: model,
timeout: 1_000,
uart_opts: [speed: 9600, data_bits: 8, stop_bits: 1, parity: :none]
)
# Query local DB
current_db = Slave.get_db(slave)
# Stop slave
Slave.stop(slave)
5. Vendor Custom Function Codes (FcMap)
For Server and Slave roles, function codes are mapped to memory regions using Modbuv.FcMap. You can register custom function codes during startup:
# Register:
# - FC 0x64 as a read of holding registers (:hr)
# - FC 0x6E as a multiple-write of holding registers (:hr, :multi)
custom_map = %{
0x64 => {:r, :reg, :hr},
0x6E => {:w, :reg, :hr, :multi}
}
# Provide to TCP Server or RTU Slave
{:ok, server} = Modbuv.Tcp.Server.start_link(port: 502, model: model, fc_map: custom_map)
{:ok, slave} = Modbuv.Rtu.Slave.start_link(tty: "/dev/ttyUSB1", model: model, fc_map: custom_map)
6. Helpers
IEEE754 Float Conversion
alias Modbuv.IEEE754
# Big-endian (high register first)
IEEE754.from_2_regs(0x40A0, 0x0000, :be) # => 5.0
IEEE754.to_2_regs(5.0, :be) # => [0x40A0, 0x0000]
# Little-endian (low register first)
IEEE754.from_2_regs(0x0000, 0x40A0, :le) # => 5.0
IEEE754.to_2_regs(5.0, :le) # => [0x0000, 0x40A0]
# Multiple floats
IEEE754.from_2n_regs([0xC0A0, 0x0000, 0x40A0, 0x0000], :be) # => [-5.0, 5.0]
CRC16 Calculation
Modbuv.CRC.crc(<<0x01, 0x03, 0x00, 0x00, 0x00, 0x01>>) # => 0x0A84
7. Telemetry
TCP Client and RTU Master requests emit [:modbuv, :request, :start] and
[:modbuv, :request, :stop] events:
:telemetry.attach(
"modbuv-rtt",
[:modbuv, :request, :stop],
fn _event, %{duration: duration}, metadata, _config ->
rtt_us = System.convert_time_unit(duration, :native, :microsecond)
IO.puts(
"#{metadata.transport} #{metadata.slave}/#{metadata.fc} " <>
"rtt=#{rtt_us}us result=#{metadata.result}"
)
end,
nil
)
- Stop measurements are
%{duration: native, monotonic_time: native}; RTT isduration. - Metadata is
%{transport: :tcp | :rtu, role: :client | :master, mode: :passive | :active, slave: slave, fc: fc}. The stop event addsresult: :ok | :errorandreason: nil | term. - Loss rate: count stop events with
result: :error(for examplereason: :timeout). - Requests still in flight when the client is closed do not emit a stop event. Active mode also has no per-request timeout, so a lost active response is not reported. Use passive mode when you need loss detection.
Testing
# Run the standard suite (hardware-tagged tests excluded)
mix test
# Run the full suite including the two RTU virtual-serial tests
socat -d -d pty,raw,echo=0,link=/tmp/modbuv_tnt0 pty,raw,echo=0,link=/tmp/modbuv_tnt1 &
MODBUV_TTY_MASTER=/tmp/modbuv_tnt0 MODBUV_TTY_SLAVE=/tmp/modbuv_tnt1 mix test --include hardware
License
This project is licensed under the MIT License - see the LICENSE file for details.