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.sendfor 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.
modbus_client.send Action
Section titled “modbus_client.send Action”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());'Configuration Variables
Section titled “Configuration Variables”-
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 is1-247, and0is 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, notstd::vector):return {0x03, 0x00, 0x10, 0x00, 0x01};works, and so does directly returning the result of amodbus::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, andon_not_sentfires. -
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 inon_not_sent(nothing was transmitted). Exactly one ofon_response/on_error/on_no_response/on_not_sentfollows 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:
requestandresponse, bothstd::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 inon_response) andexception_code(modbus::ExceptionCode, an enum - cast toint/uint8_tto print). There is noresponsespan, 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:truere-queues (retries) the frame,falsegives 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 nestedretry: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: 0x01pdu: [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.
triesabove is a global, and it must be reset before this send fires, not from a handler:on_sentfires 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).
Building PDUs with Helpers
Section titled “Building PDUs with Helpers”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)-valuesis a span ofuint16_t.create_write_coils_pdu(start_address, values)-valuesis a span ofbool, astd::vector<bool>(which is bit-packed and so needs its own overload), orPackedBitsif the bits are already packed. The device-levelwrite_multiple_coils()takes only the span andPackedBitsforms.
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.
Typed Read/Write Actions
Section titled “Typed Read/Write Actions”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.
Common Configuration Variables
Section titled “Common Configuration Variables”- address (Required, templatable, int): The Modbus device address the frame is sent to. Accepts
0-255; the valid Modbus device range is1-247, and0is 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 separateread_addressandwrite_addressinstead.start_address + count(or+ values.size()for a write) must not exceed0x10000; 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, andon_not_sentfires instead of the send. The same holds for the per-actioncountandvaluesbounds below: a lambda returning a count or list length outside its documented range also produces an empty PDU and fireson_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_responsewhen the reply doesn’t parse as a standard-conforming transaction for this function code. Lambda variables:request,response(rawstd::span<const uint8_t>PDUs) - the same shapemodbus_client.send’son_responseuses, 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>) andexception_code(modbus::ExceptionCode, an enum - cast toint/uint8_tto print). - on_no_response (Optional): Fires when the device does not answer within the hub’s
send_wait_time. Same three forms asmodbus_client.send’son_no_responseabove: a returning lambda, athen:automation, or athen:automation with a nestedretry: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).
Configuration Variables
Section titled “Configuration Variables”- 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;"modbus_client.read_input_registers Action
Section titled “modbus_client.read_input_registers Action”Reads one or more input registers (function code 0x04).
Configuration Variables
Section titled “Configuration Variables”- 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]);'modbus_client.read_coils Action
Section titled “modbus_client.read_coils Action”Reads one or more coils (function code 0x01).
Configuration Variables
Section titled “Configuration Variables”- 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]);'modbus_client.read_discrete_inputs Action
Section titled “modbus_client.read_discrete_inputs Action”Reads one or more discrete inputs (function code 0x02).
Configuration Variables
Section titled “Configuration Variables”- 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).
Configuration Variables
Section titled “Configuration Variables”- 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: 0x1234modbus_client.write_single_coil Action
Section titled “modbus_client.write_single_coil Action”Writes a single coil (function code 0x05).
Configuration Variables
Section titled “Configuration Variables”- 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: truemodbus_client.write_multiple_registers Action
Section titled “modbus_client.write_multiple_registers Action”Writes one or more consecutive holding registers (function code 0x10).
Configuration Variables
Section titled “Configuration Variables”- values (Required, templatable, list of int): Registers to write,
start_addressfirst. 1-123 registers. As a lambda it must return astd::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"modbus_client.write_multiple_coils Action
Section titled “modbus_client.write_multiple_coils Action”Writes one or more consecutive coils (function code 0x0F).
Configuration Variables
Section titled “Configuration Variables”- values (Required, templatable, list of boolean): Coils to write,
start_addressfirst. 1-1968 coils. As a lambda it must return astd::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().
Configuration Variables
Section titled “Configuration Variables”- write_address (Required, templatable, int): Address of the first holding register to write.
- values (Required, templatable, list of int): Registers to write,
write_addressfirst. 1-121 registers - the0x17write ceiling, lower than the 123 of a plain write. As a lambda it must return astd::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);Behavior
Section titled “Behavior”- 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_sentfires per transmission attempt (except a broadcast to address0, which is never answered and gets onlyon_sent); a retried frame runs the timeout branch again on each attempt. The typed read/write actions also haveon_custom_response, which fires in place ofon_responsefor 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
xof a containingon_value), and the targetedaddressis 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_sentfires when the frame goes out and no terminal handler follows. It does not occupy the waiting slot or wait the fullsend_wait_time, so it never blocks the bus, and aretry:lambda is never invoked. Only writes, read/write-multiple (0x17), and custom/vendor codes may be broadcast; a read to address0can never deliver a result and is refused up front, resolving immediately viaon_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.
Examples
Section titled “Examples”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 commandA 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);'Lambda Hook Component
Section titled “Lambda Hook Component”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.
Configuration Variables
Section titled “Configuration Variables”- 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();'See Also
Section titled “See Also”- For polling sensors mapped to registers, use Modbus Controller; for serving
registers, use Modbus Server.
modbus_clientis the ad-hoc escape hatch. - C++ component authors should subclass
modbus::ModbusClientDeviceinstead of using these actions. - Modbus Component
- API Reference: modbus.h