PyModbus is a free, open-source Python library (BSD license) that implements the full Modbus protocol stack—TCP, RTU, and ASCII—in both synchronous and asynchronous modes. Install it with pip install pymodbus, create a client with AsyncModbusTcpClient or AsyncModbusSerialClient, and you can read holding registers from a building controller in under ten lines of code. This guide covers the PyModbus 3.x async API, which is the recommended approach for all new scripts.
Why Script Modbus with Python
Many building automation controllers—variable frequency drives, power meters, boiler controllers, and standalone DDC boards—expose their data over Modbus rather than BACnet. While a handheld Modbus scanner works for checking a single register, it becomes impractical when you need to poll dozens of devices or log values over time. Python scripting with PyModbus fills that gap. Common use cases include:
- Commissioning verification — sweep through every VFD on a chilled water loop and confirm that speed feedback registers read within expected ranges
- Overnight data logging — poll power meters every 30 seconds for 24 hours and export the data to CSV for energy analysis
- Register map validation — read every documented register from a new controller model and confirm the vendor's register map matches actual device behavior
- Batch setpoint writes — push updated scheduling parameters to 20 unit controllers in seconds instead of configuring each one through a keypad
- Integration testing — verify that a supervisory controller can write to a subordinate device and read back the expected response before going live
PyModbus handles all the low-level protocol framing, CRC calculation, and response parsing. You write straightforward Python calls like client.read_holding_registers(address=0, count=10, slave=1) and get structured result objects back.
Installing PyModbus
PyModbus requires Python 3.8 or later (Python 3.10+ recommended). Install the latest stable release from PyPI:
pip install pymodbusFor serial (RTU/ASCII) communication, install the serial transport dependency as well:
pip install pymodbus[serial]Virtual environment recommended: Isolate your Modbus tooling from other Python projects to avoid dependency conflicts:
python -m venv modbus-env
source modbus-env/bin/activate # Linux/macOS
modbus-env\Scripts\activate # Windows
pip install pymodbus[serial]Verify the install by running python -c "import pymodbus; print(pymodbus.__version__)". You should see 3.12.1 or later.
Reading Holding Registers (RTU and TCP Examples)
Holding registers (function code 0x03) are the most commonly used Modbus data type in building automation. They store 16-bit values such as temperatures, setpoints, and equipment status codes. PyModbus 3.x provides both synchronous and asynchronous clients. The async API is recommended for new scripts because it handles timeouts cleanly and scales well when polling multiple devices.
Modbus TCP Example
Use AsyncModbusTcpClient to read registers from a TCP-connected device such as a power meter or a Modbus TCP gateway:
import asyncio
from pymodbus.client import AsyncModbusTcpClient
async def read_tcp():
async with AsyncModbusTcpClient(
host="192.168.1.100",
port=502,
timeout=3
) as client:
# Read 10 holding registers starting at address 0, slave ID 1
result = await client.read_holding_registers(
address=0, count=10, slave=1
)
if not result.isError():
print(f"Registers: {result.registers}")
else:
print(f"Error: {result}")
asyncio.run(read_tcp())The async with context manager connects on entry and disconnects on exit, ensuring the TCP socket is always closed. The slave parameter sets the Modbus unit ID—use 1 for most TCP devices, but check the device documentation if you are going through a serial-to-TCP gateway that addresses multiple RTU devices behind it.
Modbus RTU (Serial) Example
Use AsyncModbusSerialClient to communicate over RS-485 with RTU framing. This is the typical setup when a laptop is connected to a Modbus trunk via a USB-to-RS-485 adapter:
import asyncio
from pymodbus.client import AsyncModbusSerialClient
async def read_rtu():
client = AsyncModbusSerialClient(
port="/dev/ttyUSB0", # COM3 on Windows
baudrate=9600,
bytesize=8,
parity="N",
stopbits=1,
timeout=3
)
await client.connect()
# Read 5 holding registers from slave 2, starting at address 100
result = await client.read_holding_registers(
address=100, count=5, slave=2
)
if not result.isError():
print(f"Registers: {result.registers}")
# Convert raw register to engineering units
temp_raw = result.registers[0]
temp_f = temp_raw / 10.0
print(f"Supply Air Temp: {temp_f} F")
else:
print(f"Error: {result}")
client.close()
asyncio.run(read_rtu())Match your serial parameters exactly. Baudrate, parity, and stop bits must match the device's configuration. Mismatched settings are the number-one cause of communication failures on RTU networks. Check the controller's front panel or configuration tool for its current serial settings.
Reading Input Registers
Input registers (function code 0x04) are read-only registers that typically hold live sensor values like temperatures, pressures, and flow rates. The API call mirrors holding registers:
async def read_inputs():
async with AsyncModbusTcpClient("192.168.1.100", timeout=3) as client:
# Read 4 input registers starting at address 0, slave 1
result = await client.read_input_registers(
address=0, count=4, slave=1
)
if not result.isError():
labels = [
"Zone Temp", "Supply Temp",
"Return Temp", "Outdoor Temp"
]
for label, val in zip(labels, result.registers):
print(f"{label}: {val / 10.0} F")
else:
print(f"Read error: {result}")Many building controllers use holding registers for both read-only and read-write data, so always consult the device's register map to determine which function code to use. If read_input_registers returns an illegal function exception, try read_holding_registers instead.
Writing Registers
Use write_register() (function code 0x06) for a single register or write_registers() (function code 0x10) for multiple consecutive registers:
async def write_setpoint():
async with AsyncModbusTcpClient("192.168.1.100", timeout=3) as client:
# Write a single register: set occupied cooling setpoint to 74.0 F
# Register value = 740 (74.0 * 10), per device register map
result = await client.write_register(
address=40, value=740, slave=1
)
if not result.isError():
print("Setpoint written successfully")
# Write multiple registers in one request
# Example: set schedule start hour (reg 50) and end hour (reg 51)
result = await client.write_registers(
address=50, values=[6, 18], slave=1
)
if not result.isError():
print("Schedule written: 6:00 AM to 6:00 PM")Always verify writes by reading back the register immediately after writing. Some controllers reject out-of-range values silently—the write appears to succeed, but the register retains its previous value. A read-after-write confirms the controller accepted the new value.
# Write-then-verify pattern
await client.write_register(address=40, value=740, slave=1)
verify = await client.read_holding_registers(address=40, count=1, slave=1)
if not verify.isError() and verify.registers[0] == 740:
print("Write verified")
else:
print("WARNING: Write may not have been accepted")Batch Reading Multiple Devices
A common field task is polling the same register set from every device on a trunk or subnet. Use asyncio.gather() for TCP devices that can be queried in parallel, or sequential polling with error handling for RTU devices (which share a single serial bus):
TCP: Parallel Reads Across Multiple Devices
import asyncio
from pymodbus.client import AsyncModbusTcpClient
DEVICES = [
{"host": "192.168.1.101", "name": "AHU-1 Power Meter"},
{"host": "192.168.1.102", "name": "AHU-2 Power Meter"},
{"host": "192.168.1.103", "name": "Chiller Meter"},
]
async def poll_device(device):
try:
async with AsyncModbusTcpClient(
device["host"], port=502, timeout=3
) as client:
result = await client.read_holding_registers(
address=0, count=4, slave=1
)
if not result.isError():
return {
"name": device["name"],
"registers": result.registers
}
return {"name": device["name"], "error": str(result)}
except Exception as e:
return {"name": device["name"], "error": str(e)}
async def main():
results = await asyncio.gather(
*[poll_device(d) for d in DEVICES]
)
for r in results:
if "error" in r:
print(f"{r['name']}: ERROR - {r['error']}")
else:
kw = r["registers"][0] / 10.0
print(f"{r['name']}: {kw} kW")
asyncio.run(main())RTU: Sequential Reads on a Shared Serial Bus
import asyncio
from pymodbus.client import AsyncModbusSerialClient
SLAVE_IDS = [1, 2, 3, 4, 5] # Five VAV controllers on one trunk
async def poll_rtu_trunk():
client = AsyncModbusSerialClient(
port="/dev/ttyUSB0",
baudrate=9600, parity="N", stopbits=1, timeout=3
)
await client.connect()
for slave_id in SLAVE_IDS:
result = await client.read_holding_registers(
address=0, count=6, slave=slave_id
)
if not result.isError():
zone_temp = result.registers[0] / 10.0
setpoint = result.registers[1] / 10.0
print(
f"Slave {slave_id}: "
f"Zone={zone_temp}F, SP={setpoint}F"
)
else:
print(f"Slave {slave_id}: No response")
# Brief pause between requests to avoid overwhelming
# slow controllers on the bus
await asyncio.sleep(0.1)
client.close()
asyncio.run(poll_rtu_trunk())RTU devices share the bus—only one transaction can occur at a time. Never use asyncio.gather() to send parallel requests over a single serial port. Each request must complete (or time out) before the next one starts.
Common PyModbus Mistakes
- Not checking
result.isError()before accessingresult.registers. PyModbus returns aModbusIOExceptionobject instead of raising a Python exception when a device does not respond or returns an error. If you skip the error check and callresult.registerson an error object, you get anAttributeError. Always checkif not result.isError()before using the result. - Mismatched serial parameters for RTU. Baudrate, parity, and stop bits must match the target device exactly. A mismatch produces CRC errors or complete silence. If you get no response from an RTU device, verify serial settings against the controller's configuration screen before investigating anything else.
- Off-by-one register addressing. Modbus documentation traditionally uses 1-based register numbers (e.g., register 40001), but the protocol wire format and PyModbus both use 0-based addressing. If a register map says “holding register 40001,” pass
address=0to PyModbus. When a register map says “register 100,” check whether it means protocol address 99 or 100—the document may or may not have already subtracted the offset. - Requesting too many registers in one read. The Modbus specification limits a single read to 125 holding registers (250 bytes). Requesting more triggers an illegal data value exception. If you need to read a large block, split it into multiple requests of 100 or fewer registers each.
- Using the wrong slave ID. Every device on a Modbus network has a unique slave (unit) ID, typically between 1 and 247. Sending a request to the wrong slave ID produces silence (no device responds) or reads data from the wrong controller. Confirm the slave ID through the device's configuration menu or front-panel display before scripting.
Platform Compatibility
| Component | Requirement | Notes |
|---|---|---|
| Python | 3.8+ | 3.10 or later recommended for best async support |
| PyModbus | 3.12.1 (latest stable) | BSD license; actively maintained with frequent releases |
| Serial support | pip install pymodbus[serial] | Installs pyserial for RS-232/RS-485 communication |
| Windows | Full support | Use COM port names (COM3, COM4) for serial connections |
| Linux | Full support | Serial ports at /dev/ttyUSB0 or /dev/ttyS0; works on Raspberry Pi |
| macOS | Full support | Serial ports at /dev/tty.usbserial-*; TCP works out of the box |
USB-to-RS-485 adapters: For RTU communication, you need a USB-to-RS-485 converter. Common chipsets include FTDI FT232R and CH340. On Windows, install the manufacturer's driver and note the assigned COM port number in Device Manager. On Linux, the adapter typically appears as /dev/ttyUSB0 automatically. Ensure your user account has permission to access the serial port (sudo usermod -aG dialout $USER on most Linux distributions).
Firewall considerations for TCP: Modbus TCP uses port 502 by default. If you cannot connect, verify that no host-based firewall is blocking outbound connections to port 502. Some environments use non-standard ports (e.g., 5020)—check the device or gateway configuration.
Source Attribution
This tutorial draws from the following sources:
- PyModbus GitHub Repository — source code, examples, and issue tracker (BSD license, 2,200+ stars)
- PyModbus ReadTheDocs — official documentation covering client API, examples, and API change notes for version 3.x
- PyModbus Client API Reference — complete method signatures for synchronous and asynchronous clients
PyModbus is maintained by the pymodbus-dev community and licensed under the BSD 3-Clause License. You may use it freely in commercial and proprietary projects without restriction.
Was this article helpful?
Related Articles
Need to do this remotely? SiteConduit provides Layer 2 access that preserves BACnet broadcasts — no BBMD needed for remote sessions. Join the waitlist.
SiteConduit Technical Team
Idea Networks Inc.
SiteConduit builds managed remote access for building automation. Our knowledge base is maintained by BAS professionals with hands-on experience deploying and troubleshooting BACnet, Niagara, Modbus, and Facility Explorer systems.