Skip to content
Get started

Modbus Client

The modbus_client component provides actions for ad-hoc Modbus request/response exchanges from automations and lambdas, using an existing Modbus hub in the client role.

The actions below do not require a modbus_client: configuration block: the component loads automatically together with the modbus hub. Each action instance is its own client device on the hub, and the hub routes the reply back to the exact action that sent the request. Because replies are matched by action identity rather than by device address, the address can be templatable and overlapping sends from different actions do not interfere with each other.

A Modbus hub in the client role (the default) is required. modbus_id is only needed when multiple hubs are configured.

TIP

Three ways to send an ad-hoc command, in order of preference:

  • Typed Read/Write Actions (below) for a standard read, write, or combined read/write with a static function code - use this unless one of the next two applies.
  • modbus_client.send for a non-standard PDU, or a function code chosen at runtime instead of fixed in the YAML.
  • Lambda Hook Component for a write from inside a lambda that’s already doing other C++ work, rather than as a separate automation action - it has no way to read a reply back.

This action queues a custom Modbus frame for transmission and optionally handles the outcome.

on_...:
- modbus_client.send:
address: 0x01
pdu: [0x03, 0x00, 0x10, 0x00, 0x01]
on_response:
- lambda: 'ESP_LOGI("modbus", "got %u bytes", response.size());'
  • modbus_id (Optional, ID): The Modbus hub to send on. Automatically assigned when a single hub is configured.

  • address (Required, templatable, int): The device address the frame is sent to. Accepts 0-255; the valid Modbus device range is 1-247, and 0 is broadcast (no reply will ever arrive; see Behavior).

  • pdu (Required, templatable, list of bytes): The PDU to send: function code followed by data. The device address and CRC are added automatically - do not include them. Maximum 253 bytes (the Modbus PDU limit): an empty or over-long YAML list fails config validation. As a lambda it must return a modbus::helpers::PduBuffer (a fixed-capacity stack vector, not std::vector): return {0x03, 0x00, 0x10, 0x00, 0x01}; works, and so does directly returning the result of a modbus::helpers::create_*_pdu() builder - see Building PDUs with Helpers. Unlike the YAML list, a lambda result isn’t length-checked: one over 253 bytes is silently truncated to the limit rather than rejected. An empty lambda result (or a builder that fails - see below) also produces an empty PDU; the hub refuses to send it, and on_not_sent fires.

  • on_sent (Optional, Automation): Fires when the frame is written to the wire. Lambda variable: request (std::span<const uint8_t>, the PDU sent). This fires once per transmission, before any reply, and does not fire when the send ends in on_not_sent (nothing was transmitted). Exactly one of on_response/on_error/on_no_response/on_not_sent follows each transmission attempt - a retry reopens this cycle for its own attempt; see Behavior.

  • on_response (Optional, Automation): Fires when a matching reply arrives. Lambda variables: request and response, both std::span<const uint8_t> (PDUs: function code plus data, no address/CRC). The spans are only valid inside the handler - copy bytes out if they must outlive it.

  • on_error (Optional, Automation): Fires on a Modbus exception response. Lambda variables: request (std::span<const uint8_t>, the request PDU this action sent - same as in on_response) and exception_code (modbus::ExceptionCode, an enum - cast to int/uint8_t to print). There is no response span, as an exception carries no data payload.

  • on_no_response (Optional): Fires when the device does not answer within the hub’s send_wait_time. request (std::span<const uint8_t>, the PDU that got no reply) is available in every form. Three ways to write it:

    • a returning lambda (on_no_response: !lambda "return <bool>;") - its boolean is returned to the hub: true re-queues (retries) the frame, false gives up. The lambda can also log or bump a counter inline;

    • a then: automation (list of actions) - runs on timeout, no retry;

    • a then: automation with a nested retry: returning lambda - runs the actions and decides the retry:

      - lambda: "id(tries) = 0;" # reset before each new send, not in a handler
      - modbus_client.send:
      address: 0x01
      pdu: [0x03, 0x00, 0x10, 0x00, 0x01]
      on_no_response:
      then:
      - logger.log: "no reply, retrying"
      retry: !lambda "return id(tries)++ < 3;"

    The retry decision must be a lambda because an automation cannot return a value. The hub does not bound retries - the lambda must (for example by capping on a counter), otherwise the frame is retried forever. tries above is a global, and it must be reset before this send fires, not from a handler: on_sent fires on every retransmission (including retries), so resetting there would zero the counter on each retry and defeat the cap; resetting only on success (on_response) leaves a stale, already-capped counter for the next independent send if this one gives up.

  • on_not_sent (Optional, Automation): Fires when the frame never reached the wire - for example, an empty PDU, a duplicate write already pending (see Duplicate suppression), a full send queue, or cancelled by another device or automation clearing the queue for the same address. Distinct from a timeout: nothing was transmitted. Lambda variable: request (std::span<const uint8_t>, the PDU that was not sent).

The typed actions below call these same builders internally, so for static YAML config, use a typed action instead. Reach for a builder directly only when a typed action can’t fit: the function code needs to be chosen at runtime (create_read_pdu takes a modbus::FunctionCode value, so one lambda can serve all four read types), or you’re already writing a pdu lambda for something non-standard and want part of it built for you.

  • create_read_pdu(function_code, start_address, count) - READ_COILS, READ_DISCRETE_INPUTS, READ_HOLDING_REGISTERS, READ_INPUT_REGISTERS.
  • create_write_single_register_pdu(address, value) / create_write_single_coil_pdu(address, on_off).
  • create_write_registers_pdu(start_address, values) - values is a span of uint16_t.
  • create_write_coils_pdu(start_address, values) - values is a span of bool, a std::vector<bool> (which is bit-packed and so needs its own overload), or PackedBits if the bits are already packed. The device-level write_multiple_coils() takes only the span and PackedBits forms.

All return types convert to PduBuffer. Invalid input (bad counts, oversize payloads, …) returns an empty PDU and logs the reason - the action’s on_not_sent fires, same as any other empty PDU (see above).

globals:
- id: use_input_registers
type: bool
initial_value: 'false'
on_...:
- modbus_client.send:
address: 0x01
pdu: !lambda |-
// the function code is chosen at runtime, which a typed action can't do
auto fc = id(use_input_registers) ? modbus::FunctionCode::READ_INPUT_REGISTERS
: modbus::FunctionCode::READ_HOLDING_REGISTERS;
return modbus::helpers::create_read_pdu(fc, 0x0010, 2);

The enum and the builders live in the modbus component’s namespaces (modbus::FunctionCode, modbus::helpers::*); no extra include is needed in YAML lambdas.

Instead of hand-assembling a PDU with modbus_client.send, these actions issue a single standard Modbus function-code transaction directly: a register or coil read, a register or coil write, or a combined read/write in a single transaction. Like modbus_client.send, each is its own hub device - it resolves the hub (automatically when a single one is configured), sends the request to address, and routes the reply straight back to that action’s own handlers, never another action’s.

  • address (Required, templatable, int): The Modbus device address the frame is sent to. Accepts 0-255; the valid Modbus device range is 1-247, and 0 is broadcast (no reply will ever arrive; see Behavior).
  • modbus_id (Optional, ID): The Modbus hub to send on. Automatically assigned when a single hub is configured.
  • start_address (Required, templatable, int): The starting register or coil address. Not used by read_write_multiple_registers, which takes separate read_address and write_address instead. start_address + count (or + values.size() for a write) must not exceed 0x10000; a literal value is rejected at config time, but a templated one bypasses that check and fails at runtime - an out-of-range address produces an empty PDU, and on_not_sent fires instead of the send. The same holds for the per-action count and values bounds below: a lambda returning a count or list length outside its documented range also produces an empty PDU and fires on_not_sent.
  • on_sent (Optional, Automation): Fires when the frame is written to the wire. Lambda variable: request (std::span<const uint8_t>, the PDU sent).
  • on_response (Optional, Automation): Fires on a standard-conforming reply. The lambda variable it carries depends on the action - see below.
  • on_custom_response (Optional, Automation): Fires instead of on_response when the reply doesn’t parse as a standard-conforming transaction for this function code. Lambda variables: request, response (raw std::span<const uint8_t> PDUs) - the same shape modbus_client.send’s on_response uses, so a non-conforming device is still handleable. If left unconfigured, no handler runs for that transmission - the first non-conforming reply for this action logs a warning, and any after that log at verbose level instead.
  • on_error (Optional, Automation): Fires on a Modbus exception response. Lambda variables: request (std::span<const uint8_t>) and exception_code (modbus::ExceptionCode, an enum - cast to int/uint8_t to print).
  • on_no_response (Optional): Fires when the device does not answer within the hub’s send_wait_time. Same three forms as modbus_client.send’s on_no_response above: a returning lambda, a then: automation, or a then: automation with a nested retry: returning lambda.
  • on_not_sent (Optional, Automation): Fires when the frame never reached the wire - a duplicate write already pending, a full send queue, an invalid request, or a queued frame cancelled by another device or automation clearing the queue for the same address. See Behavior. Lambda variable: request (std::span<const uint8_t>, the PDU that was not sent).

modbus_client.read_holding_registers Action

Section titled “modbus_client.read_holding_registers Action”

Reads one or more holding registers (function code 0x03).

  • count (Optional, templatable, int): 1-125 registers to read. Defaults to 1.

on_response lambda variable: values (std::span<const uint16_t>, host byte order). A reply whose length doesn’t match the requested count is diverted to on_custom_response, so inside on_response values.size() always equals count.

globals:
- id: retries
type: int
initial_value: '0'
button:
- platform: template
name: "Read holding registers"
on_press:
- lambda: "id(retries) = 0;" # reset before each new send, not in a handler
- modbus_client.read_holding_registers:
address: 0x01
start_address: 0x0010
count: 2
on_response:
then:
- lambda: 'ESP_LOGD("modbus", "reg0=%u reg1=%u", values[0], values[1]);'
on_error:
then:
- lambda: 'ESP_LOGW("modbus", "exception 0x%02X", (uint8_t) exception_code);'
on_no_response: !lambda "return id(retries)++ < 3;"

Reads one or more input registers (function code 0x04).

  • count (Optional, templatable, int): 1-125 registers to read. Defaults to 1.

on_response lambda variable: values (std::span<const uint16_t>, host byte order). A reply whose length doesn’t match the requested count is diverted to on_custom_response, so inside on_response values.size() always equals count.

button:
- platform: template
name: "Read input registers"
on_press:
- modbus_client.read_input_registers:
address: 0x01
start_address: 0x0000
count: 1
on_response:
then:
- lambda: 'ESP_LOGD("modbus", "input reg = %u", values[0]);'

Reads one or more coils (function code 0x01).

  • count (Optional, templatable, int): 1-2000 coils to read. Defaults to 1.

on_response lambda variable: bits (modbus::PackedBits, an indexable bit view - bits[i] is coil start_address + i). A reply whose length doesn’t match the requested count is diverted to on_custom_response, so inside on_response bits.size() always equals count. operator[] is unchecked, so don’t index past it.

button:
- platform: template
name: "Read coils"
on_press:
- modbus_client.read_coils:
address: 0x01
start_address: 0x0000
count: 8
on_response:
then:
- lambda: 'ESP_LOGD("modbus", "coil 0 = %d", (int) bits[0]);'

Reads one or more discrete inputs (function code 0x02).

  • count (Optional, templatable, int): 1-2000 discrete inputs to read. Defaults to 1.

on_response lambda variable: bits (modbus::PackedBits, an indexable bit view - bits[i] is input start_address + i). A reply whose length doesn’t match the requested count is diverted to on_custom_response, so inside on_response bits.size() always equals count. operator[] is unchecked, so don’t index past it.

button:
- platform: template
name: "Read discrete inputs"
on_press:
- modbus_client.read_discrete_inputs:
address: 0x01
start_address: 0x0000
count: 4
on_response:
then:
- lambda: 'ESP_LOGD("modbus", "input 0 = %d", (int) bits[0]);'

modbus_client.write_single_register Action

Section titled “modbus_client.write_single_register Action”

Writes a single holding register (function code 0x06).

  • value (Required, templatable, int): The value to write.

on_response fires once the write is acknowledged; it carries no lambda variables at all.

button:
- platform: template
name: "Write single register"
on_press:
- modbus_client.write_single_register:
address: 0x01
start_address: 0x0020
value: 0x1234

Writes a single coil (function code 0x05).

  • value (Required, templatable, boolean): The value to write.

on_response fires once the write is acknowledged; it carries no lambda variables at all.

button:
- platform: template
name: "Write single coil"
on_press:
- modbus_client.write_single_coil:
address: 0x01
start_address: 0x0000
value: true

modbus_client.write_multiple_registers Action

Section titled “modbus_client.write_multiple_registers Action”

Writes one or more consecutive holding registers (function code 0x10).

  • values (Required, templatable, list of int): Registers to write, start_address first. 1-123 registers. As a lambda it must return a std::vector<uint16_t>.

on_response fires once the write is acknowledged; it carries no lambda variables at all.

button:
- platform: template
name: "Write multiple registers"
on_press:
- modbus_client.write_multiple_registers:
address: 0x01
start_address: 0x0020
values: [0x0001, 0x0002, 0x0003]
on_not_sent:
then:
- logger.log: "write refused: hub queue full or duplicate pending"

Writes one or more consecutive coils (function code 0x0F).

  • values (Required, templatable, list of boolean): Coils to write, start_address first. 1-1968 coils. As a lambda it must return a std::vector<bool>.

on_response fires once the write is acknowledged; it carries no lambda variables at all.

button:
- platform: template
name: "Write multiple coils"
on_press:
- modbus_client.write_multiple_coils:
address: 0x01
start_address: 0x0000
values: [true, false, true]

modbus_client.read_write_multiple_registers Action

Section titled “modbus_client.read_write_multiple_registers Action”

Writes one block of holding registers and reads another back in a single transaction (function code 0x17). Per the Modbus specification the write is performed before the read, so when the two ranges overlap the read returns the values just written. Because the write travels in the same frame, an on_no_response retry re-sends it - avoid retries on registers whose writes have side effects (command or trigger registers, counters, one-shot commits). For queue priority and duplicate suppression the hub classifies 0x17 as a write, so a second identical send while one is in flight is refused with on_not_sent rather than requeued the way a plain read would be.

This action does not use the common start_address; it has a separate read range and write range instead. The 0x10000 range rule described there applies to each pair independently - read_address + read_count and write_address + values.size().

  • write_address (Required, templatable, int): Address of the first holding register to write.
  • values (Required, templatable, list of int): Registers to write, write_address first. 1-121 registers - the 0x17 write ceiling, lower than the 123 of a plain write. As a lambda it must return a std::vector<uint16_t>.
  • read_address (Required, templatable, int): Address of the first holding register to read back.
  • read_count (Optional, templatable, int): 1-125 registers to read back. Defaults to 1.

on_response lambda variable: values (std::span<const uint16_t>, host byte order) - the block that was read back, the same shape as read_holding_registers. Note this is a different values from the block being written in the configuration above.

button:
- platform: template
name: "Read/write multiple registers"
on_press:
- modbus_client.read_write_multiple_registers:
address: 0x01
write_address: 0x0010
values: [0x1234, 0x5678]
read_address: 0x0020
read_count: 4
on_response:
then:
- lambda: |-
// `values` here is the read-back block, not the block written above
for (auto v : values)
ESP_LOGD("modbus", "read 0x%04X", v);
  • Fire-and-continue: the action queues the frame and the enclosing automation continues immediately; the reply handlers run later, when the outcome is known. Exactly one of on_response/on_error/on_no_response/ on_not_sent fires per transmission attempt (except a broadcast to address 0, which is never answered and gets only on_sent); a retried frame runs the timeout branch again on each attempt. The typed read/write actions also have on_custom_response, which fires in place of on_response for a non-conforming reply.
  • The reply handlers run with the action’s own context - they do not see the enclosing automation’s local variables (such as the x of a containing on_value), and the targeted address is not passed back to them; recompute the configured expression if a handler needs it.
  • Duplicate suppression: sending an identical frame while one from the same action is already in flight is safe. For a read, the second send is rescheduled for a second transmission at the back of the queue once the first completes, and still gets its own reply handler. For a write or custom command, only one may be in flight - a second identical send is refused (on_not_sent).
  • Broadcast (address 0): a broadcast is never answered (Modbus 4.1), so the hub treats it as fire-and-forget - on_sent fires when the frame goes out and no terminal handler follows. It does not occupy the waiting slot or wait the full send_wait_time, so it never blocks the bus, and a retry: lambda is never invoked. Only writes, read/write-multiple (0x17), and custom/vendor codes may be broadcast; a read to address 0 can never deliver a result and is refused up front, resolving immediately via on_not_sent.

NOTE

None of the reply handlers (on_sent, on_response, on_custom_response, on_error, on_no_response, on_not_sent) may contain deferring actions (delay, wait_until, script.wait, …) - the request/response data is only valid while the handler runs. Config validation rejects such a handler with an error. Copy any bytes you need into a global first, then defer from a separate script or automation.

A minimal fire-and-forget write:

button:
- platform: template
name: "Reset energy counter"
on_press:
- modbus_client.send:
address: 0x01
pdu: [0x42] # vendor-specific reset command

A read with reply handling. A read response PDU is [function code][byte count][data...], so the first register is in bytes 2-3 (big-endian). Decode manually as below, or extract the payload with modbus::helpers::server_pdu_payload(response) instead of hardcoding the offset yourself - it applies the read-vs-exception offset for you and returns an empty span for a too-short PDU:

button:
- platform: template
name: "Read holding register 0x10"
on_press:
- modbus_client.send:
address: 0x01
pdu: [0x03, 0x00, 0x10, 0x00, 0x01] # fc 0x03, start 0x0010, count 1
on_response:
- lambda: |-
if (response.size() >= 4)
id(my_value).publish_state((response[2] << 8) | response[3]);

A templated device address:

- modbus_client.send:
address: !lambda "return id(target_address);"
pdu: [0x03, 0x00, 0x00, 0x00, 0x02]

Handling the error and timeout branches:

- modbus_client.send:
address: 0x01
pdu: [0x03, 0x00, 0x10, 0x00, 0x01]
on_error:
- lambda: |-
// request[0] is the function code we sent
ESP_LOGW("modbus", "fc 0x%02X exception %d",
request.empty() ? 0 : request[0], (int) exception_code);
on_no_response:
- lambda: 'id(device_online).publish_state(false);'

A simpler alternative to the actions above, for use only inside a lambda - not a YAML automation action. It’s syntactic sugar over the id(hub).queue_pdu(address, pdu) call shown on the Modbus page: the device’s address is configured once instead of passed to every call, and the write_* methods build the PDU for you. There’s no callback of any kind, so there’s no way to read a reply back - useful for writes only, never reads. Commands sent this way are queued on the hub just like any other, so firing off several in quick succession from the same lambda is supported.

  • id (Required, ID): The ID to use in lambdas. Unlike most components it is not generated for you: the device is only reachable through id(), so an entry without one is rejected.
  • address (Required, int): The Modbus device address every call from this client targets. Unlike Modbus Controller, there is no default.
  • modbus_id (Optional, ID): The Modbus hub to send on. Automatically assigned when a single hub is configured.

Several clients may be configured, each bound to its own device address.

modbus_client:
- id: ad_hoc_client
address: 0x01
on_...:
- lambda: 'id(ad_hoc_client).write_single_register(0x1234, 0x99AB);'
- lambda: |-
uint16_t registers[] = {0x00FF, 0xAABC, 0xF00D};
id(ad_hoc_client).write_multiple_registers(0x9013, registers);
- lambda: 'id(ad_hoc_client).write_single_coil(0x1234, true);'
- lambda: |-
bool coils[] = {true, false, false, true};
id(ad_hoc_client).write_multiple_coils(0x1234, coils);
# Any arbitrary PDU (function code + data)
- lambda: |-
uint8_t pdu[] = {42};
id(ad_hoc_client).queue_pdu(pdu);
# Clear the send queue for all commands targeting this client's address
- lambda: 'id(ad_hoc_client).clear_tx_queue_for_address();'
# Clear the send queue for all commands sent from this client component
- lambda: 'id(ad_hoc_client).clear_tx_queue_for_device();'