Skip to content
Get started

Modbus Controller

The modbus_controller component creates an RS485 connection to control a Modbus server (slave) device, letting your ESPHome node act as a Modbus client (master). You can access the coils, inputs, holding, read registers from your devices as sensors, switches, selects, numbers or various other ESPHome components and present them to your favorite Home Automation system. You can even write them as binary or float outputs from ESPHome.

Set the role attribute of the Modbus upon which this modbus_controller component relies to client, which is the default.

See Modbus for RS485 hardware setup instructions.

  • modbus_id (Optional, ID): Manually specify the ID of the modbus hub.

  • address (Optional, int): The Modbus device address (can be decimal or hexadecimal, 0-255; the valid Modbus device range is 1-247). 0 is the broadcast address, which no device ever answers, so it cannot be polled. Defaults to 0x01.

  • update_interval (Optional, Time): The interval that the sensors should be checked. Defaults to 60 seconds.

  • offline_skip_updates (Optional, integer): How many update intervals to wait between retry attempts while a device is offline (see below). Defaults to 0 (retry every update interval). If using a bus with multiple slaves, this avoids waiting for timeouts on an unresponsive device, allowing the other slaves on the same bus to be read on time.

  • max_cmd_retries (Optional, integer): How many times a command will be retried if no response is received. It doesn’t include the initial transmission. Defaults to 4.

Multiple modbus_controller instances can share the same address — for example, to poll different registers on the same device at different update_intervals.

Non-responses are counted per device, not per command or register. Once the initial send and all max_cmd_retries retries have timed out - max_cmd_retries + 1 consecutive timeouts in total - the whole device is marked offline (triggering on_offline), its queued frames are dropped, and all of its updates are skipped. Every offline_skip_updates + 1 update intervals the controller probes again by polling all of its ranges (each following the normal retry ladder). Any response, including a Modbus exception response, brings the device back online (triggering on_online) and normal polling resumes.

Automations:

  • on_command_sent (Optional, Automation): An automation to perform when a modbus command has been sent. See on_command_sent
  • on_online (Optional, Automation): An automation to perform when a modbus controller goes online. See on_online
  • on_offline (Optional, Automation): An automation to perform when a modbus controller goes offline. See on_offline

WARNING

These options no longer have any effect. They are still accepted so existing configurations keep working — setting either one logs a validation warning at compile time — and will be removed in 2027.2.0:

  • command_throttle: Use turnaround_time on the modbus component instead.
  • allow_duplicate_commands: The request queue (including duplicate handling) is the responsibility of the modbus hub. No configuration is required.

These options have been removed and raise a validation error at compile time:

  • server_registers and server_courtesy_response: Use the Modbus Server component instead.

The following code creates a modbus_controller hub talking to a Modbus device at address 1 with 115200 bps

Each Modbus entity (sensor, switch, number, and so on) is configured as its own platform component and linked to a hub with modbus_controller_id. When only one modbus_controller is defined, modbus_controller_id may be omitted and the entity attaches to that hub automatically.

# Example configuration entry
uart:
...
modbus:
flow_control_pin: GPIOXX
id: modbus1
modbus_controller:
- id: modbus_device
address: 0x1 ## address of the Modbus slave device on the bus
modbus_id: modbus1
setup_priority: -10
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_device
name: "Battery Capacity"
register_type: holding
address: 0x9001 ## address of the register inside the Modbus slave device
unit_of_measurement: "AH"
value_type: U_WORD
switch:
- platform: modbus_controller
modbus_controller_id: modbus_device
name: "Reset to Factory Default"
register_type: coil
address: 0x15
bitmask: 1
text_sensor:
- name: "rtc_clock"
platform: modbus_controller
modbus_controller_id: modbus_device
id: rtc_clock
internal: true
register_type: holding
address: 0x9013
register_count: 3
raw_encode: HEXBYTES
response_size: 6

The configuration example above creates a modbus_controller hub talking to a Modbus device at address 1 with a baudrate of 115200 bps, implementing a sensor, a switch and a text sensor.

The following code allows a Modbus client to read a sensor value from your ESPHome node, that the node itself read from a Modbus server.

uart:
- id: uart_modbus_client
tx_pin: 32
rx_pin: 34
- id: uart_modbus_server
tx_pin: 25
rx_pin: 35
modbus:
- uart_id: uart_modbus_client
id: modbus_client_hub
- uart_id: uart_modbus_server
id: modbus_server
role: server
modbus_controller:
- id: modbus_evse
modbus_id: modbus_client_hub
address: 0x2
update_interval: 5s
modbus_server:
- modbus_id: modbus_server
address: 0x4
registers:
- address: 0x0002
value_type: S_DWORD_R
read_lambda: |-
return id(evse_voltage_l1).state;
sensor:
- platform: modbus_controller
id: evse_voltage_l1
modbus_controller_id: modbus_evse
name: "EVSE voltage L1"
register_type: holding
address: 0x0000
device_class: voltage
value_type: S_DWORD_R
accuracy_decimals: 1
unit_of_measurement: V
filters:
- multiply: 0.1

Check out the various Modbus components available at the bottom of the document in the See Also section. Each is configured as its own platform component and linked to a hub with modbus_controller_id (which may be omitted when only one modbus_controller is defined).

Below you find a few general tips about using Modbus in more advanced scenarios. Applicable component functionalities have links pointing here:

Some devices use decimal values in read registers to show multiple binary states occupying only one register address. To decode them, you can use bitmasks according to the table below. The decimal value corresponding to a bit is always double of the previous one in the row. Multiple bits can be represented in a single register by making a sum of all the values corresponding to the bits.

Alarm bitDescriptionDEC valueHEX value
bit 0Binary Sensor 011
bit 1Binary Sensor 122
bit 2Binary Sensor 244
bit 3Binary Sensor 388
bit 4Binary Sensor 41610
bit 5Binary Sensor 53220
bit 6Binary Sensor 66440
bit 7Binary Sensor 712880
bit 8Binary Sensor 8256100
bit 9Binary Sensor 9512200
bit 10Binary Sensor 101024400
bit 11Binary Sensor 112048800
bit 12Binary Sensor 1240961000
bit 13Binary Sensor 1381922000
bit 14Binary Sensor 14163844000
bit 15Binary Sensor 15327688000

In the example below, register 15, holds several binary values. It stores the decimal value 12288, which is the sum of 4096 + 8192, meaning the corresponding bits 12 and 13 are 1, the other bits are 0.

To gather some of these bits as binary sensors in ESPHome, use bitmask :

binary_sensor:
- platform: modbus_controller
modbus_controller_id: modbus1
name: Alarm bit0
register_type: input
address: 15
bitmask: 0x1
- platform: modbus_controller
modbus_controller_id: modbus1
name: Alarm bit1
register_type: input
address: 15
bitmask: 0x2
- platform: modbus_controller
modbus_controller_id: modbus1
name: Alarm bit10
register_type: input
address: 15
bitmask: 0x400
- platform: modbus_controller
modbus_controller_id: modbus1
name: Alarm bit15
register_type: input
address: 15
bitmask: 0x8000

custom_command can be used to create an arbitrary modbus command. Combined with a lambda any response can be handled. This example re-implements the command to read the registers 0x156 (Total active energy) and 0x158 Total (reactive energy) from a SDM-120. SDM-120 returns the values as floats using 32 bits in 2 registers.

uart:
id: mod_uart
...
modbus:
send_wait_time: 200ms
uart_id: mod_uart
id: modbus
modbus_controller:
- id: sdm
address: 2
modbus_id: modbus
setup_priority: -10
update_interval: 30s
sensor:
- platform: modbus_controller
modbus_controller_id: sdm
name: "Total active energy"
id: total_energy
# address: 0x156
# register_type: "read"
## reimplement using custom_command
# 0x2 : modbus device address
# 0x4 : modbus function code
# 0x1 : high byte of modbus register address
# 0x56: low byte of modbus register address
# 0x00: high byte of total number of registers requested
# 0x02: low byte of total number of registers requested
custom_command: [ 0x2, 0x4, 0x1, 0x56,0x00, 0x02]
value_type: FP32
unit_of_measurement: kWh
accuracy_decimals: 1
- platform: modbus_controller
modbus_controller_id: sdm
name: "Total reactive energy"
# address: 0x158
# register_type: "read"
custom_command: [0x2, 0x4, 0x1, 0x58, 0x00, 0x02]
## the command returns an float value using 4 bytes
lambda: |-
ESP_LOGD("Modbus Sensor Lambda","Got new data" );
union {
float float_value;
uint32_t raw;
} raw_to_float;
if (data.size() < 4 ) {
ESP_LOGE("Modbus Sensor Lambda", "invalid data size %d",data.size());
return NAN;
}
raw_to_float.raw = data[0] << 24 | data[1] << 16 | data[2] << 8 | data[3];
ESP_LOGD("Modbus Sensor Lambda", "FP32 = 0x%08X => %f", raw_to_float.raw, raw_to_float.float_value);
return raw_to_float.float_value;
unit_of_measurement: kVArh
accuracy_decimals: 1

register_count is an option only required for uncommon response encodings or to optimizie modbus communications.

It describes the number of registers this data point spans, overriding the defaults determined by value_type. If no value for register_count is provided, it is calculated based on the register type. The default size for one register is 16 bits (one word). Some devices are not adhering to this convention and have registers larger than 16 bits. In this case, register_count and response_size must be set. For example, if your Modbus device uses one register for a FP32 value (instead of the default of two), set register_count: 1 and response_size: 4.

register_count can also be used to skip a number of registers in consecutive range.

An example is an SDM meter, with interesting data in register addresses 0, 2, 4 and 6:

- platform: modbus_controller
name: "Voltage Phase 1"
address: 0
register_type: "read"
value_type: FP32
- platform: modbus_controller
name: "Voltage Phase 2"
address: 2
register_type: "read"
value_type: FP32
- platform: modbus_controller
name: "Voltage Phase 3"
address: 4
register_type: "read"
value_type: FP32
- platform: modbus_controller
name: "Current Phase 1"
address: 6
register_type: "read"
value_type: FP32
accuracy_decimals: 1

The configuration above will generate one modbus command read multiple registers from 0 to 6.

Maybe you don’t care about the data in register addresses 2 and 4, which are voltage values for Phase 2 and Phase 3 (or you have a SDM-120). Of course, you can delete the sensors your don’t care about, but then you’d have a gap in the addresses. If you remove the registers at address 2 and 4, two commands will be generated — read register 0 and read register 6. To avoid generating multiple commands and thus reduce activity on the bus, register_count can be used to fill the gaps:

- platform: modbus_controller
name: "Voltage Phase 1"
address: 0
unit_of_measurement: "V"
register_type: "read"
value_type: FP32
register_count: 6
- platform: modbus_controller
name: "Current Phase 1"
address: 6
register_type: "read"
value_type: FP32

Because the option register_count: 6 is used for the first sensor, one command read multiple registers from 0 to 6 will be used but the values in between will be ignored.

NOTE

Calculation: FP32 is a 32 bit value and uses 2 registers. Therefore, to skip the 2 FP32 registers the size of these 2 registers must be added to the default size for the first register. So we have 2 for address 0, 2 for address 2 and 2 for address 4 thus register_count must be 6.

sensor:
- platform: modbus_controller
modbus_controller_id: epever
id: array_rated_voltage
name: "array_rated_voltage"
address: 0x3000
unit_of_measurement: "V"
register_type: input
value_type: U_WORD
accuracy_decimals: 1
skip_updates: 60
filters:
- multiply: 0.01
- platform: modbus_controller
modbus_controller_id: epever
id: array_rated_current
name: "array_rated_current"
address: 0x3001
unit_of_measurement: "V"
register_type: input
value_type: U_WORD
accuracy_decimals: 2
filters:
- multiply: 0.01
- platform: modbus_controller
modbus_controller_id: epever
id: array_rated_power
name: "array_rated_power"
address: 0x3002
unit_of_measurement: "W"
register_type: input
value_type: U_DWORD_R
accuracy_decimals: 1
filters:
- multiply: 0.01
- platform: modbus_controller
modbus_controller_id: epever
id: battery_rated_voltage
name: "battery_rated_voltage"
address: 0x3004
unit_of_measurement: "V"
register_type: input
value_type: U_WORD
accuracy_decimals: 1
filters:
- multiply: 0.01
- platform: modbus_controller
modbus_controller_id: epever
id: battery_rated_current
name: "battery_rated_current"
address: 0x3005
unit_of_measurement: "A"
register_type: input
value_type: U_WORD
accuracy_decimals: 1
filters:
- multiply: 0.01
- platform: modbus_controller
modbus_controller_id: epever
id: battery_rated_power
name: "battery_rated_power"
address: 0x3006
unit_of_measurement: "W"
register_type: input
value_type: U_DWORD_R
accuracy_decimals: 1
filters:
- multiply: 0.01
- platform: modbus_controller
modbus_controller_id: epever
id: charging_mode
name: "charging_mode"
address: 0x3008
unit_of_measurement: ""
register_type: input
value_type: U_WORD
accuracy_decimals: 0

To minimize the required transactions all registers with the same base address are read in one request. The response is mapped to the sensor based on register_count and offset in bytes. For example:

Request:

datadescription
0x1 (01)device address
0x4 (04)function code 4 (Read Input Registers)
0x30 (48)start address high byte
0x0 (00)start address low byte
0x0 (00)number of registers to read high byte
0x9 (09)number of registers to read low byte
0x3f (63)crc
0xc (12)crc

Response:

offsetdatavalue (type)description
H0x1 (01)device address
H0x4 (04)function code
H0x12 (18)byte count
00x27 (39)U_WORDarray_rated_voltage high byte
10x10 (16)0x2710 (100000)array_rated_voltage low byte
20x7 (7)U_WORDarray_rated_current high byte
30xd0 (208)0x7d0 (2000)array_rated_current low byte
40xcb (203)U_DWORD_Rarray_rated_power high byte of low word
50x20 (32)spans 2 registerarray_rated_power low byte of low word
60x0 (0)array_rated_power high byte of high word
70x0 (0)0x0000CB20 (52000)array_rated_power low byte of high word
80x9 (09)U_WORDbattery_rated_voltage high byte
90x60 (96)0x960 (2400)battery_rated_voltage low byte
100x7 (07)U_WORDbattery_rated_current high word
110xd0 (208)0x7d0 (2000)battery_rated_current high word
120xcb (203)U_DWORD_Rbattery_rated_power high byte of low word
130x20 (32)spans 2 registerbattery_rated_power low byte of low word
140x0 (0)battery_rated_power high byte of high word
150x0 (0)0x0000CB20 (52000)battery_rated_power low byte of high word
160x0 (0)U_WORDcharging_mode high byte
170x2 (02)0x2 (MPPT)charging_mode low byte
C0x2f (47)crc
C0x31 (49)crc

NOTE

Write support is only implemented for numbers, outputs, switches and selects. If you want ad-hoc write support see Modbus Client.

This automation will be triggered when a command has been transmitted by the modbus hub. It fires once per wire transmission, so a command that is retried triggers it again on each attempt. In Lambdas you can get the function code in function_code and the register address in address.

modbus_controller:
- id: modbus_con
# ...
on_command_sent:
then:
- number.increment: modbus_commands

This automation will be triggered when a modbus_controller goes online, after been offline. In Lambdas you can get the function code in function_code and the register address in address.

modbus_controller:
- id: modbus_con
# ...
on_online:
then:
- logger.log: "Controller back online!"

This automation will be triggered when a modbus_controller goes offline (see Offline behavior). In Lambdas you can get the function code in function_code and the register address in address.

modbus_controller:
- id: modbus_con
# ...
on_offline:
then:
- logger.log: "Controller goes offline!"