Modbus transport#
Modbus transport driver for building custom register-mapped instrument drivers
ModbusTCPTransport and ModbusRTUTransport are the Modbus transports that register-mapped instrument drivers sit on top of. They are a public part of the library so customers can build their own drivers for Modbus-attached instruments (temperature and process controllers, meters, PLCs) without wrapping pymodbus themselves.
They are intentionally narrow: a transport opens, closes, and locks a Modbus TCP or RTU connection and exposes raw function-code I/O plus typed register encode and decode. The caller owns the register map (which address holds what).
Note
Modbus is a register-and-coil protocol. A driver reads and writes numbered 16-bit registers and single-bit coils by address; there is no self-describing command set. The Modbus transports use pymodbus under the hood and support both Modbus TCP and Modbus RTU (serial).
When to reach for it#
The Modbus transports address Modbus TCP and RTU (serial) devices, exposing raw function-code ops plus a typed codec. Reach for one when the register map is fixed in code, or when you want a standalone client addressing registers by number. For a comparison against the other transports, see Transports.
For a config-driven device where the register map lives in a JSON file rather than driver code, use ModbusDevice instead. ModbusDevice composes a Modbus transport and adds semantic access by register alias, scaling, validation, and background polling.
Quickstart#
A Modbus transport holds no unit address of its own: it serves any number of unit addresses on the line. Every I/O method takes the target’s unit_id as a keyword argument:
from instro.lib.transports import ModbusTCPTransport
modbus = ModbusTCPTransport(host="192.168.1.50", port=502)
modbus.open()
try:
# Raw function-code access by address.
setpoint_regs = modbus.read_holding_registers(0x0100, count=2, unit_id=1)
# Typed access decodes across registers, applying byte/word/long swaps.
process_value = modbus.read_typed("input", 0x0000, "float32", unit_id=1)
modbus.write_typed("holding", 0x0100, 72.5, "float32", unit_id=1)
finally:
modbus.close()
Use ModbusRTUTransport for serial devices. It supports both Modbus serial-line transmission modes: RTU framing (the default) and ASCII framing, via framer:
from instro.lib.transports import ModbusRTUTransport
modbus = ModbusRTUTransport(port="/dev/ttyUSB0", baudrate=19200)
ascii_modbus = ModbusRTUTransport(port="/dev/ttyUSB1", framer="ascii")
Register and data types#
Modbus defines four address spaces. The Modbus transports name them with the RegisterType vocabulary, and the typed access path dispatches on it:
|
Width |
Access |
Function codes |
|---|---|---|---|
|
16-bit register |
read and write |
FC03 read, FC06 write one, FC16 write many |
|
16-bit register |
read only |
FC04 |
|
single bit |
read and write |
FC01 read, FC05 write one, FC15 write many |
|
single bit |
read only |
FC02 |
Values wider than 16 bits span consecutive registers. DataType names the encoding, and register_count() reports the span:
|
Registers |
Notes |
|---|---|---|
|
1 |
|
|
2 |
|
|
4 |
|
|
1 |
The only type valid for |
Typed access#
read_typed and write_typed handle the multi-register encode and decode, so callers work in native Python types rather than assembling 16-bit words:
process_value = modbus.read_typed("input", 0x0000, "float32", unit_id=1)
modbus.write_typed("holding", 0x0100, 72.5, "float32", unit_id=1)
Modbus itself does not specify how a multi-register value is ordered, so vendors differ. Three keyword flags cover the common permutations, all defaulting to False (big-endian, high word first):
Flag |
Effect |
|---|---|
|
Reverses the two bytes within each register |
|
Swaps the 16-bit words within each 32-bit group |
|
Swaps the 32-bit halves of a 64-bit value |
# A device that reports float32 low-word-first.
flow_rate = modbus.read_typed("input", 0x0010, "float32", unit_id=1, word_swap=True)
Four rules the typed path enforces:
"input"and"discrete"are read-only.write_typedraisesValueErrorrather than issuing a doomed request.Single-bit spaces require
"bool". Passing any otherdata_typefor"coil"or"discrete"raisesValueError.Coil writes require an actual
bool. There is no numeric coercion, sowrite_typed("coil", addr, 1, "bool")raises rather than silently treating1asTrue.Register count follows from the data type.
read_typedreads exactly the spanregister_count()reports, so callers never pass a count.
register_count, decode_registers, and encode_value are also available as module-level functions for callers that hold raw registers already and only need the codec.
Atomic multi-step sequences#
Hold the transport lock across several ops to keep them atomic, for example selecting a page or bank register and then reading from it:
with modbus.lock():
modbus.write_holding_register(0x00FF, page, unit_id=1)
values = modbus.read_holding_registers(0x0000, count=8, unit_id=1)
Note that a transport error inside the block closes the dead socket before re-raising, so the next op after the with reconnects rather than reusing it.
Configuration#
ModbusTCPTransport#
Field |
Type |
Default |
Notes |
|---|---|---|---|
|
|
required |
Hostname or IP address |
|
|
|
1 to 65535 |
|
|
|
Response timeout in seconds |
ModbusRTUTransport#
Field |
Type |
Default |
Notes |
|---|---|---|---|
|
|
required |
Serial device, e.g. |
|
|
|
|
|
|
|
None, even, odd |
|
|
|
|
|
|
|
|
|
|
|
Response timeout in seconds |
|
|
|
Modbus serial-line transmission mode |
Neither transport carries a unit address: every I/O method takes unit_id as a keyword argument. The valid range is 0 to 255 on ModbusTCPTransport and 0 to 247 on ModbusRTUTransport (248-255 are reserved on a real serial line, never assigned to a slave).
Method reference#
Method |
Purpose |
|---|---|
|
Construct the transport. No I/O yet. |
|
Validate a unit address against the transport’s range (0-255 for TCP, 0-247 for RTU) and return it. |
|
Property: |
|
Connect. Idempotent. See Lifecycle. |
|
Disconnect. See Shared ownership. |
|
The reentrant lock, for atomic multi-step sequences. |
|
FC03. Returns |
|
FC04. Returns |
|
FC06. Writes one 16-bit register. |
|
FC16. Writes consecutive registers. |
|
FC01. Returns |
|
FC05. Writes one coil. |
|
FC15. Writes consecutive coils. |
|
FC02. Returns |
|
Read and decode across registers. |
|
Encode and write across registers. |
|
Module-level function. Registers the type spans. |
|
Module-level function. Raw registers to a typed value. |
|
Module-level function. Typed value to raw registers. |
Error handling#
Error |
Cause |
|---|---|
|
An op was issued before |
|
The device returned a Modbus exception response. The name and code come from the standard set below. |
|
|
|
A typed-access rule was violated: writing a read-only space, a non- |
Device-side failures carry the standard Modbus exception-code name, so the message identifies the protocol-level cause rather than just reporting a failure:
Code |
Name |
Code |
Name |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Tip
The pymodbus synchronous client does not reconnect on its own between operations. The Modbus transport closes the dead socket when an op fails with a transport error and re-raises, so the next call establishes a fresh connection. Application code still has to decide whether to retry; the transport does not retry for you.