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). 1-247 is the standard device range. 0 is the broadcast address and is now rejected at validation - an existing config using address: 0 will fail to compile; a broadcast never returns a response, so use the Modbus Client component for broadcast or other ad-hoc frames. Values from 248 to 255 are still accepted but fall outside the standard range; the field is a single byte, so 255 is the maximum. 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.

  • continuous (Optional, boolean): Poll as fast as the bus allows instead of once per update interval. Defaults to false. See Continuous polling below.

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.

NOTE

The per-platform skip_updates option (set on individual sensors, not the controller) also no longer has any effect and will be removed in 2027.3.0. To poll some registers less often, give them their own modbus_controller sharing the same address with a slower update_interval (see above). The individual platform pages document this.

With continuous: true every read this controller makes is handed to the Modbus hub as a continuous poll: the read is re-queued as soon as its reply has been handled, so polls stream back to back and run about as fast as the bus allows, paced by the hub’s turnaround_time. This fills time the bus would otherwise spend idle.

A continuous poll ranks below every one-shot request in the queue, so it never starves ordinary traffic: a write (or one-shot read) queued while a poll is running goes out as soon as the poll’s current frame completes, and the poll resumes after it. Several outstanding continuous polls - a controller’s multiple register ranges, or polls from different controllers - take turns: the hub always serves the least-recently-polled one next, so none is starved. continuous applies to reads only - the hub strips it from any function code that changes something on the device.

update_interval keeps its meaning as the recovery path: a continuous poll ends when its read fails or is cancelled, and the next update re-submits it, so a device that stops answering is retried on the normal polling cadence - or, once the device has been marked offline, on the offline_skip_updates cadence. Set update_interval to a sensible value even when polling continuously.

Continuous polling suits a device that should be read with as little delay as possible, typically one with a small set of registers. The trade-off is that the bus is kept busy, which matters when several devices share it.

modbus_controller:
- id: controller1
address: 0x1
# Reads stream continuously to fill idle bus time; update_interval
# becomes the retry cadence if the device stops responding.
continuous: true
update_interval: 30s

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
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_pdu can be used to create an arbitrary modbus command. Combined with a lambda any response can be handled. The value is the modbus PDU — the function code followed by its data. The device address (taken from the controller’s address:) and the CRC are added automatically, so do not include a leading device-address byte. 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.

IMPORTANT

custom_pdu replaces the former custom_command option. custom_command took a raw frame that began with the modbus device-address byte; custom_pdu takes the PDU only (function code + data). To migrate, drop the leading device-address byte — it now comes from the controller’s address:. The old custom_command: key is removed and raises a config-validation error pointing at custom_pdu.

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_pdu
# 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
# the device address (0x2) comes from the controller's address: and the CRC is appended automatically
custom_pdu: [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"
# the device address comes from the controller's address:; supply the PDU only
custom_pdu: [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

Sensors of the same register type are grouped into register ranges, and each range is read with one modbus command. The number of registers an item spans is derived from its value_type (or from response_size for text sensors), and the reuse_previous_range option controls how an item relates to the range built just before it:

  • auto (default): join when the addresses are adjacent and the item’s position in the reply is exact - never past a register with a non-standard response_size, where the position depends on the device honoring it.
  • true: join unconditionally, reading across address gaps (the gap registers are read and ignored) and past registers with a non-standard response_size. Joined ranges are capped at the protocol read limits; an over-long join starts a new range with a warning instead of sending an out-of-spec request.
  • false: always start a new range at this item; later items may still extend it.

The deprecated force_new_range and register_count options migrate automatically: force_new_range: true becomes reuse_previous_range: false with a deprecation warning, and a register_count equal to the derived width warns that it is redundant (a different value fails validation with instructions). Both will be removed in 2027.3.0. The old recipe for a device that packs a wider value into a single register - register_count: 1 with response_size: 4 for an FP32 - has no replacement: a typed value’s width now always comes from its value_type.

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 you 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, reuse_previous_range: true joins across the gap:

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

Because reuse_previous_range: true is set on the sensor at address 6, one command read multiple registers from 0 to 6 will be used and the gap registers in between are read and ignored - no size arithmetic required.

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
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 each sensor’s register width 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!"